?_txlock=immediate` is recommended.
- # https://github.com/mattn/go-sqlite3#connection-string
- # Postgres: Connection string. For example, postgres://user:password@host/database?sslmode=disable
- # To connect via Unix socket, use something like postgres:///dbname?host=/var/run/postgresql
- uri: postgres://user:password@host/database?sslmode=disable
- # Maximum number of connections. Mostly relevant for Postgres.
- max_open_conns: 20
- max_idle_conns: 2
- # Maximum connection idle time and lifetime before they're closed. Disabled if null.
- # Parsed with https://pkg.go.dev/time#ParseDuration
- max_conn_idle_time: null
- max_conn_lifetime: null
-
- # The unique ID of this appservice.
- id: whatsapp
- # Appservice bot details.
- bot:
- # Username of the appservice bot.
- username: whatsappbot
- # Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty
- # to leave display name/avatar as-is.
- displayname: WhatsApp bridge bot
- avatar: mxc://maunium.net/NeXNQarUbrlYBiPCpprYsRqr
-
- # Whether or not to receive ephemeral events via appservice transactions.
- # Requires MSC2409 support (i.e. Synapse 1.22+).
- ephemeral_events: true
-
- # Should incoming events be handled asynchronously?
- # This may be necessary for large public instances with lots of messages going through.
- # However, messages will not be guaranteed to be bridged in the same order they were sent in.
- async_transactions: false
-
- # Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
- as_token: "This value is generated when generating the registration"
- hs_token: "This value is generated when generating the registration"
-
-# Segment-compatible analytics endpoint for tracking some events, like provisioning API login and encryption errors.
-analytics:
- # Hostname of the tracking server. The path is hardcoded to /v1/track
- host: api.segment.io
- # API key to send with tracking requests. Tracking is disabled if this is null.
- token: null
- # Optional user ID for tracking events. If null, defaults to using Matrix user ID.
- user_id: null
-
-# Prometheus config.
-metrics:
- # Enable prometheus metrics?
- enabled: false
- # IP and port where the metrics listener should be. The path is always /metrics
- listen: 127.0.0.1:8001
-
-# Config for things that are directly sent to WhatsApp.
-whatsapp:
- # Device name that's shown in the "WhatsApp Web" section in the mobile app.
- os_name: Mautrix-WhatsApp bridge
- # Browser name that determines the logo shown in the mobile app.
- # Must be "unknown" for a generic icon or a valid browser name if you want a specific icon.
- # List of valid browser names: https://github.com/tulir/whatsmeow/blob/efc632c008604016ddde63bfcfca8de4e5304da9/binary/proto/def.proto#L43-L64
- browser_name: unknown
- # Proxy to use for all WhatsApp connections.
- proxy: null
- # Alternative to proxy: an HTTP endpoint that returns the proxy URL to use for WhatsApp connections.
- get_proxy_url: null
- # Whether the proxy options should only apply to the login websocket and not to authenticated connections.
- proxy_only_login: false
-
-# Bridge config
-bridge:
- # Localpart template of MXIDs for WhatsApp users.
- # {{.}} is replaced with the phone number of the WhatsApp user.
- username_template: whatsapp_{{.}}
- # Displayname template for WhatsApp users.
- # {{.PushName}} - nickname set by the WhatsApp user
- # {{.BusinessName}} - validated WhatsApp business name
- # {{.Phone}} - phone number (international format)
- # The following variables are also available, but will cause problems on multi-user instances:
- # {{.FullName}} - full name from contact list
- # {{.FirstName}} - first name from contact list
- displayname_template: "{{or .BusinessName .PushName .JID}} (WA)"
- # Should the bridge create a space for each logged-in user and add bridged rooms to it?
- # Users who logged in before turning this on should run `!wa sync space` to create and fill the space for the first time.
- personal_filtering_spaces: false
- # Should the bridge send a read receipt from the bridge bot when a message has been sent to WhatsApp?
- delivery_receipts: false
- # Whether the bridge should send the message status as a custom com.beeper.message_send_status event.
- message_status_events: false
- # Whether the bridge should send error notices via m.notice events when a message fails to bridge.
- message_error_notices: true
- # Should incoming calls send a message to the Matrix room?
- call_start_notices: true
- # Should another user's cryptographic identity changing send a message to Matrix?
- identity_change_notices: false
- portal_message_buffer: 128
- # Settings for handling history sync payloads.
- history_sync:
- # Enable backfilling history sync payloads from WhatsApp?
- backfill: true
- # The maximum number of initial conversations that should be synced.
- # Other conversations will be backfilled on demand when receiving a message or when initiating a direct chat.
- max_initial_conversations: -1
- # Maximum number of messages to backfill in each conversation.
- # Set to -1 to disable limit.
- message_count: 50
- # Should the bridge request a full sync from the phone when logging in?
- # This bumps the size of history syncs from 3 months to 1 year.
- request_full_sync: false
- # Configuration parameters that are sent to the phone along with the request full sync flag.
- # By default (when the values are null or 0), the config isn't sent at all.
- full_sync_config:
- # Number of days of history to request.
- # The limit seems to be around 3 years, but using higher values doesn't break.
- days_limit: null
- # This is presumably the maximum size of the transferred history sync blob, which may affect what the phone includes in the blob.
- size_mb_limit: null
- # This is presumably the local storage quota, which may affect what the phone includes in the history sync blob.
- storage_quota_mb: null
- # If this value is greater than 0, then if the conversation's last message was more than
- # this number of hours ago, then the conversation will automatically be marked it as read.
- # Conversations that have a last message that is less than this number of hours ago will
- # have their unread status synced from WhatsApp.
- unread_hours_threshold: 0
-
- ###############################################################################
- # The settings below are only applicable for backfilling using batch sending, #
- # which is no longer supported in Synapse. #
- ###############################################################################
-
- # Settings for media requests. If the media expired, then it will not be on the WA servers.
- # Media can always be requested by reacting with the ♻️ (recycle) emoji.
- # These settings determine if the media requests should be done automatically during or after backfill.
- media_requests:
- # Should expired media be automatically requested from the server as part of the backfill process?
- auto_request_media: true
- # Whether to request the media immediately after the media message is backfilled ("immediate")
- # or at a specific time of the day ("local_time").
- request_method: immediate
- # If request_method is "local_time", what time should the requests be sent (in minutes after midnight)?
- request_local_time: 120
- # Maximum number of media request responses to handle in parallel per user.
- max_async_handle: 2
- # Settings for immediate backfills. These backfills should generally be small and their main purpose is
- # to populate each of the initial chats (as configured by max_initial_conversations) with a few messages
- # so that you can continue conversations without losing context.
- immediate:
- # The number of concurrent backfill workers to create for immediate backfills.
- # Note that using more than one worker could cause the room list to jump around
- # since there are no guarantees about the order in which the backfills will complete.
- worker_count: 1
- # The maximum number of events to backfill initially.
- max_events: 10
- # Settings for deferred backfills. The purpose of these backfills are to fill in the rest of
- # the chat history that was not covered by the immediate backfills.
- # These backfills generally should happen at a slower pace so as not to overload the homeserver.
- # Each deferred backfill config should define a "stage" of backfill (i.e. the last week of messages).
- # The fields are as follows:
- # - start_days_ago: the number of days ago to start backfilling from.
- # To indicate the start of time, use -1. For example, for a week ago, use 7.
- # - max_batch_events: the number of events to send per batch.
- # - batch_delay: the number of seconds to wait before backfilling each batch.
- deferred:
- # Last Week
- - start_days_ago: 7
- max_batch_events: 20
- batch_delay: 5
- # Last Month
- - start_days_ago: 30
- max_batch_events: 50
- batch_delay: 10
- # Last 3 months
- - start_days_ago: 90
- max_batch_events: 100
- batch_delay: 10
- # The start of time
- - start_days_ago: -1
- max_batch_events: 500
- batch_delay: 10
-
- # Should puppet avatars be fetched from the server even if an avatar is already set?
- user_avatar_sync: true
- # Should Matrix users leaving groups be bridged to WhatsApp?
- bridge_matrix_leave: true
- # Should the bridge update the m.direct account data event when double puppeting is enabled.
- # Note that updating the m.direct event is not atomic (except with mautrix-asmux)
- # and is therefore prone to race conditions.
- sync_direct_chat_list: false
- # Should the bridge use MSC2867 to bridge manual "mark as unread"s from
- # WhatsApp and set the unread status on initial backfill?
- # This will only work on clients that support the m.marked_unread or
- # com.famedly.marked_unread room account data.
- sync_manual_marked_unread: true
- # When double puppeting is enabled, users can use `!wa toggle` to change whether
- # presence is bridged. This setting sets the default value.
- # Existing users won't be affected when these are changed.
- default_bridge_presence: true
- # Send the presence as "available" to whatsapp when users start typing on a portal.
- # This works as a workaround for homeservers that do not support presence, and allows
- # users to see when the whatsapp user on the other side is typing during a conversation.
- send_presence_on_typing: false
- # Should the bridge always send "active" delivery receipts (two gray ticks on WhatsApp)
- # even if the user isn't marked as online (e.g. when presence bridging isn't enabled)?
- #
- # By default, the bridge acts like WhatsApp web, which only sends active delivery
- # receipts when it's in the foreground.
- force_active_delivery_receipts: false
- # Servers to always allow double puppeting from
- double_puppet_server_map:
- example.com: https://example.com
- # Allow using double puppeting from any server with a valid client .well-known file.
- double_puppet_allow_discovery: false
- # Shared secrets for https://github.com/devture/matrix-synapse-shared-secret-auth
- #
- # If set, double puppeting will be enabled automatically for local users
- # instead of users having to find an access token and run `login-matrix`
- # manually.
- login_shared_secret_map:
- example.com: foobar
- # Whether to explicitly set the avatar and room name for private chat portal rooms.
- # If set to `default`, this will be enabled in encrypted rooms and disabled in unencrypted rooms.
- # If set to `always`, all DM rooms will have explicit names and avatars set.
- # If set to `never`, DM rooms will never have names and avatars set.
- private_chat_portal_meta: default
- # Should group members be synced in parallel? This makes member sync faster
- parallel_member_sync: false
- # Should Matrix m.notice-type messages be bridged?
- bridge_notices: true
- # Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run.
- # This field will automatically be changed back to false after it, except if the config file is not writable.
- resend_bridge_info: false
- # When using double puppeting, should muted chats be muted in Matrix?
- mute_bridging: false
- # When using double puppeting, should archived chats be moved to a specific tag in Matrix?
- # Note that WhatsApp unarchives chats when a message is received, which will also be mirrored to Matrix.
- # This can be set to a tag (e.g. m.lowpriority), or null to disable.
- archive_tag: null
- # Same as above, but for pinned chats. The favorite tag is called m.favourite
- pinned_tag: null
- # Should mute status and tags only be bridged when the portal room is created?
- tag_only_on_create: true
- # Should WhatsApp status messages be bridged into a Matrix room?
- # Disabling this won't affect already created status broadcast rooms.
- enable_status_broadcast: true
- # Should sending WhatsApp status messages be allowed?
- # This can cause issues if the user has lots of contacts, so it's disabled by default.
- disable_status_broadcast_send: true
- # Should the status broadcast room be muted and moved into low priority by default?
- # This is only applied when creating the room, the user can unmute it later.
- mute_status_broadcast: true
- # Tag to apply to the status broadcast room.
- status_broadcast_tag: m.lowpriority
- # Should the bridge use thumbnails from WhatsApp?
- # They're disabled by default due to very low resolution.
- whatsapp_thumbnail: false
- # Allow invite permission for user. User can invite any bots to room with whatsapp
- # users (private chat and groups)
- allow_user_invite: false
- # Whether or not created rooms should have federation enabled.
- # If false, created portal rooms will never be federated.
- federate_rooms: true
- # Should the bridge never send alerts to the bridge management room?
- # These are mostly things like the user being logged out.
- disable_bridge_alerts: false
- # Should the bridge stop if the WhatsApp server says another user connected with the same session?
- # This is only safe on single-user bridges.
- crash_on_stream_replaced: false
- # Should the bridge detect URLs in outgoing messages, ask the homeserver to generate a preview,
- # and send it to WhatsApp? URL previews can always be sent using the `com.beeper.linkpreviews`
- # key in the event content even if this is disabled.
- url_previews: false
- # Send captions in the same message as images. This will send data compatible with both MSC2530 and MSC3552.
- # This is currently not supported in most clients.
- caption_in_message: false
- # Send galleries as a single event? This is not an MSC (yet).
- beeper_galleries: false
- # Should polls be sent using MSC3381 event types?
- extev_polls: false
- # Should cross-chat replies from WhatsApp be bridged? Most servers and clients don't support this.
- cross_room_replies: false
- # Disable generating reply fallbacks? Some extremely bad clients still rely on them,
- # but they're being phased out and will be completely removed in the future.
- disable_reply_fallbacks: false
- # Maximum time for handling Matrix events. Duration strings formatted for https://pkg.go.dev/time#ParseDuration
- # Null means there's no enforced timeout.
- message_handling_timeout:
- # Send an error message after this timeout, but keep waiting for the response until the deadline.
- # This is counted from the origin_server_ts, so the warning time is consistent regardless of the source of delay.
- # If the message is older than this when it reaches the bridge, the message won't be handled at all.
- error_after: null
- # Drop messages after this timeout. They may still go through if the message got sent to the servers.
- # This is counted from the time the bridge starts handling the message.
- deadline: 120s
-
- # The prefix for commands. Only required in non-management rooms.
- command_prefix: "!wa"
-
- # Messages sent upon joining a management room.
- # Markdown is supported. The defaults are listed below.
- management_room_text:
- # Sent when joining a room.
- welcome: "Hello, I'm a WhatsApp bridge bot."
- # Sent when joining a management room and the user is already logged in.
- welcome_connected: "Use `help` for help."
- # Sent when joining a management room and the user is not logged in.
- welcome_unconnected: "Use `help` for help or `login` to log in."
- # Optional extra text sent when joining a management room.
- additional_help: ""
-
- # End-to-bridge encryption support options.
- #
- # See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html for more info.
- encryption:
- # Allow encryption, work in group chat rooms with e2ee enabled
- allow: false
- # Default to encryption, force-enable encryption in all portals the bridge creates
- # This will cause the bridge bot to be in private chats for the encryption to work properly.
- default: false
- # Whether to use MSC2409/MSC3202 instead of /sync long polling for receiving encryption-related data.
- appservice: false
- # Require encryption, drop any unencrypted messages.
- require: false
- # Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled.
- # You must use a client that supports requesting keys from other users to use this feature.
- allow_key_sharing: false
- # Should users mentions be in the event wire content to enable the server to send push notifications?
- plaintext_mentions: false
- # Options for deleting megolm sessions from the bridge.
- delete_keys:
- # Beeper-specific: delete outbound sessions when hungryserv confirms
- # that the user has uploaded the key to key backup.
- delete_outbound_on_ack: false
- # Don't store outbound sessions in the inbound table.
- dont_store_outbound: false
- # Ratchet megolm sessions forward after decrypting messages.
- ratchet_on_decrypt: false
- # Delete fully used keys (index >= max_messages) after decrypting messages.
- delete_fully_used_on_decrypt: false
- # Delete previous megolm sessions from same device when receiving a new one.
- delete_prev_on_new_session: false
- # Delete megolm sessions received from a device when the device is deleted.
- delete_on_device_delete: false
- # Periodically delete megolm sessions when 2x max_age has passed since receiving the session.
- periodically_delete_expired: false
- # Delete inbound megolm sessions that don't have the received_at field used for
- # automatic ratcheting and expired session deletion. This is meant as a migration
- # to delete old keys prior to the bridge update.
- delete_outdated_inbound: false
- # What level of device verification should be required from users?
- #
- # Valid levels:
- # unverified - Send keys to all device in the room.
- # cross-signed-untrusted - Require valid cross-signing, but trust all cross-signing keys.
- # cross-signed-tofu - Require valid cross-signing, trust cross-signing keys on first use (and reject changes).
- # cross-signed-verified - Require valid cross-signing, plus a valid user signature from the bridge bot.
- # Note that creating user signatures from the bridge bot is not currently possible.
- # verified - Require manual per-device verification
- # (currently only possible by modifying the `trust` column in the `crypto_device` database table).
- verification_levels:
- # Minimum level for which the bridge should send keys to when bridging messages from WhatsApp to Matrix.
- receive: unverified
- # Minimum level that the bridge should accept for incoming Matrix messages.
- send: unverified
- # Minimum level that the bridge should require for accepting key requests.
- share: cross-signed-tofu
- # Options for Megolm room key rotation. These options allow you to
- # configure the m.room.encryption event content. See:
- # https://spec.matrix.org/v1.3/client-server-api/#mroomencryption for
- # more information about that event.
- rotation:
- # Enable custom Megolm room key rotation settings. Note that these
- # settings will only apply to rooms created after this option is
- # set.
- enable_custom: false
- # The maximum number of milliseconds a session should be used
- # before changing it. The Matrix spec recommends 604800000 (a week)
- # as the default.
- milliseconds: 604800000
- # The maximum number of messages that should be sent with a given a
- # session before changing it. The Matrix spec recommends 100 as the
- # default.
- messages: 100
-
- # Disable rotating keys when a user's devices change?
- # You should not enable this option unless you understand all the implications.
- disable_device_change_key_rotation: false
-
- # Settings for provisioning API
- provisioning:
- # Prefix for the provisioning API paths.
- prefix: /_matrix/provision
- # Shared secret for authentication. If set to "generate", a random secret will be generated,
- # or if set to "disable", the provisioning API will be disabled.
- shared_secret: generate
- # Enable debug API at /debug with provisioning authentication.
- debug_endpoints: false
-
- # Permissions for using the bridge.
- # Permitted values:
- # relay - Talk through the relaybot (if enabled), no access otherwise
- # user - Access to use the bridge to chat with a WhatsApp account.
- # admin - User level and some additional administration tools
- # Permitted keys:
- # * - All Matrix users
- # domain - All users on that homeserver
- # mxid - Specific user
- permissions:
- "*": relay
- "example.com": user
- "@admin:example.com": admin
-
- # Settings for relay mode
- relay:
- # Whether relay mode should be allowed. If allowed, `!wa set-relay` can be used to turn any
- # authenticated user into a relaybot for that chat.
- enabled: false
- # Should only admins be allowed to set themselves as relay users?
- admin_only: true
- # The formats to use when sending messages to WhatsApp via the relaybot.
- message_formats:
- m.text: "{{ .Sender.Displayname }}: {{ .Message }}"
- m.notice: "{{ .Sender.Displayname }}: {{ .Message }}"
- m.emote: "* {{ .Sender.Displayname }} {{ .Message }}"
- m.file: "{{ .Sender.Displayname }} sent a file"
- m.image: "{{ .Sender.Displayname }} sent an image"
- m.audio: "{{ .Sender.Displayname }} sent an audio file"
- m.video: "{{ .Sender.Displayname }} sent a video"
- m.location: "{{ .Sender.Displayname }} sent a location"
-
-# Logging config. See https://github.com/tulir/zeroconfig for details.
-logging:
- min_level: debug
- writers:
- - type: stdout
- format: pretty-colored
- - type: file
- format: json
- filename: ./logs/mautrix-whatsapp.log
- max_size: 100
- max_backups: 10
- compress: true
diff --git a/formatting.go b/formatting.go
deleted file mode 100644
index 9f39020..0000000
--- a/formatting.go
+++ /dev/null
@@ -1,208 +0,0 @@
-// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
-// Copyright (C) 2023 Tulir Asokan
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package main
-
-import (
- "context"
- "fmt"
- "html"
- "regexp"
- "sort"
- "strings"
-
- "github.com/rs/zerolog"
- "go.mau.fi/whatsmeow/types"
- "golang.org/x/exp/slices"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/format"
- "maunium.net/go/mautrix/id"
-)
-
-var italicRegex = regexp.MustCompile("([\\s>~*]|^)_(.+?)_([^a-zA-Z\\d]|$)")
-var boldRegex = regexp.MustCompile("([\\s>_~]|^)\\*(.+?)\\*([^a-zA-Z\\d]|$)")
-var strikethroughRegex = regexp.MustCompile("([\\s>_*]|^)~(.+?)~([^a-zA-Z\\d]|$)")
-var codeBlockRegex = regexp.MustCompile("```(?:.|\n)+?```")
-var inlineURLRegex = regexp.MustCompile(`\[(.+?)]\((.+?)\)`)
-
-const mentionedJIDsContextKey = "fi.mau.whatsapp.mentioned_jids"
-const allowedMentionsContextKey = "fi.mau.whatsapp.allowed_mentions"
-
-type Formatter struct {
- bridge *WABridge
-
- matrixHTMLParser *format.HTMLParser
-
- waReplString map[*regexp.Regexp]string
- waReplFunc map[*regexp.Regexp]func(string) string
- waReplFuncText map[*regexp.Regexp]func(string) string
-}
-
-func NewFormatter(bridge *WABridge) *Formatter {
- formatter := &Formatter{
- bridge: bridge,
- matrixHTMLParser: &format.HTMLParser{
- TabsToSpaces: 4,
- Newline: "\n",
-
- PillConverter: func(displayname, mxid, eventID string, ctx format.Context) string {
- allowedMentions, _ := ctx.ReturnData[allowedMentionsContextKey].(map[types.JID]bool)
- if mxid[0] == '@' {
- var jid types.JID
- if puppet := bridge.GetPuppetByMXID(id.UserID(mxid)); puppet != nil {
- jid = puppet.JID
- } else if user := bridge.GetUserByMXIDIfExists(id.UserID(mxid)); user != nil {
- jid = user.JID.ToNonAD()
- }
- if !jid.IsEmpty() && (allowedMentions == nil || allowedMentions[jid]) {
- if allowedMentions == nil {
- jids, ok := ctx.ReturnData[mentionedJIDsContextKey].([]string)
- if !ok {
- ctx.ReturnData[mentionedJIDsContextKey] = []string{jid.String()}
- } else {
- ctx.ReturnData[mentionedJIDsContextKey] = append(jids, jid.String())
- }
- }
- return "@" + jid.User
- }
- }
- return displayname
- },
- BoldConverter: func(text string, _ format.Context) string { return fmt.Sprintf("*%s*", text) },
- ItalicConverter: func(text string, _ format.Context) string { return fmt.Sprintf("_%s_", text) },
- StrikethroughConverter: func(text string, _ format.Context) string { return fmt.Sprintf("~%s~", text) },
- MonospaceConverter: func(text string, _ format.Context) string { return fmt.Sprintf("```%s```", text) },
- MonospaceBlockConverter: func(text, language string, _ format.Context) string { return fmt.Sprintf("```%s```", text) },
- },
- waReplString: map[*regexp.Regexp]string{
- italicRegex: "$1$2$3",
- boldRegex: "$1$2$3",
- strikethroughRegex: "$1$2$3",
- },
- }
- formatter.waReplFunc = map[*regexp.Regexp]func(string) string{
- codeBlockRegex: func(str string) string {
- str = str[3 : len(str)-3]
- if strings.ContainsRune(str, '\n') {
- return fmt.Sprintf("%s
", str)
- }
- return fmt.Sprintf("%s
", str)
- },
- }
- formatter.waReplFuncText = map[*regexp.Regexp]func(string) string{}
- return formatter
-}
-
-func (formatter *Formatter) getMatrixInfoByJID(ctx context.Context, roomID id.RoomID, jid types.JID) (mxid id.UserID, displayname string) {
- if puppet := formatter.bridge.GetPuppetByJID(jid); puppet != nil {
- mxid = puppet.MXID
- displayname = puppet.Displayname
- }
- if user := formatter.bridge.GetUserByJID(jid); user != nil {
- mxid = user.MXID
- member, err := formatter.bridge.StateStore.GetMember(ctx, roomID, user.MXID)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).
- Stringer("room_id", roomID).
- Stringer("user_id", user.MXID).
- Msg("Failed to get member profile from state store")
- } else if len(member.Displayname) > 0 {
- displayname = member.Displayname
- }
- }
- return
-}
-
-func (formatter *Formatter) ParseWhatsApp(ctx context.Context, roomID id.RoomID, content *event.MessageEventContent, mentionedJIDs []string, allowInlineURL, forceHTML bool) {
- output := html.EscapeString(content.Body)
- for regex, replacement := range formatter.waReplString {
- output = regex.ReplaceAllString(output, replacement)
- }
- for regex, replacer := range formatter.waReplFunc {
- output = regex.ReplaceAllStringFunc(output, replacer)
- }
- if allowInlineURL {
- output = inlineURLRegex.ReplaceAllStringFunc(output, func(s string) string {
- groups := inlineURLRegex.FindStringSubmatch(s)
- return fmt.Sprintf(`%s`, groups[2], groups[1])
- })
- }
- alreadyMentioned := make(map[id.UserID]struct{})
- content.Mentions = &event.Mentions{}
- for _, rawJID := range mentionedJIDs {
- jid, err := types.ParseJID(rawJID)
- if err != nil {
- continue
- } else if jid.Server == types.LegacyUserServer {
- jid.Server = types.DefaultUserServer
- } else if jid.Server != types.DefaultUserServer {
- // TODO lid support?
- continue
- }
- mxid, displayname := formatter.getMatrixInfoByJID(ctx, roomID, jid)
- number := "@" + jid.User
- output = strings.ReplaceAll(output, number, fmt.Sprintf(`%s`, mxid, displayname))
- content.Body = strings.ReplaceAll(content.Body, number, displayname)
- if _, ok := alreadyMentioned[mxid]; !ok {
- alreadyMentioned[mxid] = struct{}{}
- content.Mentions.UserIDs = append(content.Mentions.UserIDs, mxid)
- }
- }
- if output != content.Body || forceHTML {
- output = strings.ReplaceAll(output, "\n", "
")
- content.FormattedBody = output
- content.Format = event.FormatHTML
- for regex, replacer := range formatter.waReplFuncText {
- content.Body = regex.ReplaceAllStringFunc(content.Body, replacer)
- }
- }
-}
-
-func (formatter *Formatter) ParseMatrix(html string, mentions *event.Mentions) (string, []string) {
- ctx := format.NewContext(context.TODO())
- var mentionedJIDs []string
- if mentions != nil {
- var allowedMentions = make(map[types.JID]bool)
- mentionedJIDs = make([]string, 0, len(mentions.UserIDs))
- for _, userID := range mentions.UserIDs {
- var jid types.JID
- if puppet := formatter.bridge.GetPuppetByMXID(userID); puppet != nil {
- jid = puppet.JID
- mentionedJIDs = append(mentionedJIDs, puppet.JID.String())
- } else if user := formatter.bridge.GetUserByMXIDIfExists(userID); user != nil {
- jid = user.JID.ToNonAD()
- }
- if !jid.IsEmpty() && !allowedMentions[jid] {
- allowedMentions[jid] = true
- mentionedJIDs = append(mentionedJIDs, jid.String())
- }
- }
- ctx.ReturnData[allowedMentionsContextKey] = allowedMentions
- }
- result := formatter.matrixHTMLParser.Parse(html, ctx)
- if mentions == nil {
- mentionedJIDs, _ = ctx.ReturnData[mentionedJIDsContextKey].([]string)
- sort.Strings(mentionedJIDs)
- mentionedJIDs = slices.Compact(mentionedJIDs)
- }
- return result, mentionedJIDs
-}
-
-func (formatter *Formatter) ParseMatrixWithoutMentions(html string) string {
- ctx := format.NewContext(context.TODO())
- ctx.ReturnData[allowedMentionsContextKey] = map[types.JID]struct{}{}
- return formatter.matrixHTMLParser.Parse(html, ctx)
-}
diff --git a/go.mod b/go.mod
index 247c845..9d456c5 100644
--- a/go.mod
+++ b/go.mod
@@ -8,18 +8,13 @@ require (
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.5.0
github.com/lib/pq v1.10.9
- github.com/mattn/go-sqlite3 v1.14.23
- github.com/prometheus/client_golang v1.20.3
github.com/rs/zerolog v1.33.0
- github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
- github.com/tidwall/gjson v1.17.3
go.mau.fi/util v0.8.1-0.20240925093630-1734c3c342eb
go.mau.fi/webp v0.1.0
go.mau.fi/whatsmeow v0.0.0-20240927134544-69ba055bef0f
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0
golang.org/x/image v0.20.0
golang.org/x/net v0.29.0
- golang.org/x/sync v0.8.0
google.golang.org/protobuf v1.34.2
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.21.1-0.20240927113633-d1e5b09d972b
@@ -27,20 +22,17 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
- github.com/beorn7/perks v1.0.1 // indirect
- github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/google/uuid v1.6.0 // indirect
- github.com/klauspost/compress v1.17.9 // indirect
- github.com/kr/text v0.2.0 // indirect
+ github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/mattn/go-sqlite3 v1.14.23 // indirect
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect
- github.com/prometheus/client_model v0.6.1 // indirect
- github.com/prometheus/common v0.55.0 // indirect
- github.com/prometheus/procfs v0.15.1 // indirect
+ github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/rs/xid v1.6.0 // indirect
+ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
+ github.com/tidwall/gjson v1.17.3 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
@@ -48,8 +40,10 @@ require (
go.mau.fi/libsignal v0.1.1 // indirect
go.mau.fi/zeroconfig v0.1.3 // indirect
golang.org/x/crypto v0.27.0 // indirect
+ golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
+ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
maunium.net/go/mauflag v1.0.0 // indirect
)
diff --git a/go.sum b/go.sum
index 64a8d35..c832ab0 100644
--- a/go.sum
+++ b/go.sum
@@ -2,10 +2,6 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
-github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
-github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
-github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
-github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@@ -20,14 +16,13 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
-github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
-github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@@ -38,21 +33,13 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
-github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
-github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw=
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
+github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/prometheus/client_golang v1.20.3 h1:oPksm4K8B+Vt35tUhw6GbSNSgVlVSBH0qELP/7u83l4=
-github.com/prometheus/client_golang v1.20.3/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
-github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
-github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
-github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
-github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
-github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
-github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
diff --git a/historysync.go b/historysync.go
deleted file mode 100644
index a8a2c58..0000000
--- a/historysync.go
+++ /dev/null
@@ -1,1024 +0,0 @@
-// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
-// Copyright (C) 2021 Tulir Asokan
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package main
-
-import (
- "context"
- "crypto/sha256"
- "encoding/base64"
- "fmt"
- "strings"
- "time"
-
- "github.com/rs/zerolog"
- waProto "go.mau.fi/whatsmeow/binary/proto"
- "go.mau.fi/whatsmeow/types"
-
- "go.mau.fi/util/variationselector"
-
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/appservice"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/id"
-
- "maunium.net/go/mautrix-whatsapp/config"
- "maunium.net/go/mautrix-whatsapp/database"
-)
-
-// region User history sync handling
-
-type wrappedInfo struct {
- *types.MessageInfo
- Type database.MessageType
- Error database.MessageErrorType
-
- SenderMXID id.UserID
-
- ReactionTarget types.MessageID
-
- MediaKey []byte
-
- ExpirationStart time.Time
- ExpiresIn time.Duration
-}
-
-func (user *User) handleHistorySyncsLoop() {
- if !user.bridge.Config.Bridge.HistorySync.Backfill {
- return
- }
-
- batchSend := user.bridge.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending)
- if batchSend {
- // Start the backfill queue.
- user.BackfillQueue = &BackfillQueue{
- BackfillQuery: user.bridge.DB.BackfillQueue,
- reCheckChannels: []chan bool{},
- }
-
- forwardAndImmediate := []database.BackfillType{database.BackfillImmediate, database.BackfillForward}
-
- // Immediate backfills can be done in parallel
- for i := 0; i < user.bridge.Config.Bridge.HistorySync.Immediate.WorkerCount; i++ {
- go user.HandleBackfillRequestsLoop(forwardAndImmediate, []database.BackfillType{})
- }
-
- // Deferred backfills should be handled synchronously so as not to
- // overload the homeserver. Users can configure their backfill stages
- // to be more or less aggressive with backfilling at this stage.
- go user.HandleBackfillRequestsLoop([]database.BackfillType{database.BackfillDeferred}, forwardAndImmediate)
- }
-
- if user.bridge.Config.Bridge.HistorySync.MediaRequests.AutoRequestMedia &&
- user.bridge.Config.Bridge.HistorySync.MediaRequests.RequestMethod == config.MediaRequestMethodLocalTime {
- go user.dailyMediaRequestLoop()
- }
-
- // Always save the history syncs for the user. If they want to enable
- // backfilling in the future, we will have it in the database.
- for {
- select {
- case evt := <-user.historySyncs:
- if evt == nil {
- return
- }
- user.storeHistorySync(evt.Data)
- case <-user.enqueueBackfillsTimer.C:
- if batchSend {
- user.enqueueAllBackfills()
- } else {
- user.backfillAll()
- }
- }
- }
-}
-
-const EnqueueBackfillsDelay = 30 * time.Second
-
-func (user *User) enqueueAllBackfills() {
- log := user.zlog.With().
- Str("method", "User.enqueueAllBackfills").
- Logger()
- ctx := log.WithContext(context.TODO())
- nMostRecent, err := user.bridge.DB.HistorySync.GetRecentConversations(ctx, user.MXID, user.bridge.Config.Bridge.HistorySync.MaxInitialConversations)
- if err != nil {
- log.Err(err).Msg("Failed to get recent history sync conversations from database")
- return
- } else if len(nMostRecent) == 0 {
- return
- }
- log.Info().
- Int("chat_count", len(nMostRecent)).
- Msg("Enqueueing backfills for recent chats in history sync")
- // Find the portals for all the conversations.
- portals := make([]*Portal, 0, len(nMostRecent))
- for _, conv := range nMostRecent {
- jid, err := types.ParseJID(conv.ConversationID)
- if err != nil {
- log.Err(err).Str("conversation_id", conv.ConversationID).Msg("Failed to parse chat JID in history sync")
- continue
- }
- portals = append(portals, user.GetPortalByJID(jid))
- }
-
- user.EnqueueImmediateBackfills(ctx, portals)
- user.EnqueueForwardBackfills(ctx, portals)
- user.EnqueueDeferredBackfills(ctx, portals)
-
- // Tell the queue to check for new backfill requests.
- user.BackfillQueue.ReCheck()
-}
-
-func (user *User) backfillAll() {
- log := user.zlog.With().
- Str("method", "User.backfillAll").
- Logger()
- ctx := log.WithContext(context.TODO())
- conversations, err := user.bridge.DB.HistorySync.GetRecentConversations(ctx, user.MXID, -1)
- if err != nil {
- log.Err(err).Msg("Failed to get history sync conversations from database")
- return
- } else if len(conversations) == 0 {
- return
- }
- log.Info().
- Int("conversation_count", len(conversations)).
- Msg("Probably received all history sync blobs, now backfilling conversations")
- limit := user.bridge.Config.Bridge.HistorySync.MaxInitialConversations
- bridgedCount := 0
- // Find the portals for all the conversations.
- for _, conv := range conversations {
- jid, err := types.ParseJID(conv.ConversationID)
- if err != nil {
- log.Err(err).
- Str("conversation_id", conv.ConversationID).
- Msg("Failed to parse chat JID in history sync")
- continue
- }
- portal := user.GetPortalByJID(jid)
- if portal.MXID != "" {
- log.Debug().
- Str("portal_jid", portal.Key.JID.String()).
- Msg("Chat already has a room, deleting messages from database")
- err = user.bridge.DB.HistorySync.DeleteConversation(ctx, user.MXID, portal.Key.JID.String())
- if err != nil {
- log.Err(err).Str("portal_jid", portal.Key.JID.String()).
- Msg("Failed to delete history sync conversation with existing portal from database")
- }
- bridgedCount++
- } else if hasMessages, err := user.bridge.DB.HistorySync.ConversationHasMessages(ctx, user.MXID, portal.Key); err != nil {
- log.Err(err).Str("portal_jid", portal.Key.JID.String()).Msg("Failed to check if chat has messages in history sync")
- } else if !hasMessages {
- log.Debug().Str("portal_jid", portal.Key.JID.String()).Msg("Skipping chat with no messages in history sync")
- err = user.bridge.DB.HistorySync.DeleteConversation(ctx, user.MXID, portal.Key.JID.String())
- if err != nil {
- log.Err(err).Str("portal_jid", portal.Key.JID.String()).
- Msg("Failed to delete history sync conversation with no messages from database")
- }
- } else if limit < 0 || bridgedCount < limit {
- bridgedCount++
- err = portal.CreateMatrixRoom(ctx, user, nil, nil, true, true)
- if err != nil {
- log.Err(err).Msg("Failed to create Matrix room for backfill")
- }
- }
- }
-}
-
-func (portal *Portal) legacyBackfill(ctx context.Context, user *User) {
- defer portal.latestEventBackfillLock.Unlock()
- // This should only be called from CreateMatrixRoom which locks latestEventBackfillLock before creating the room.
- if portal.latestEventBackfillLock.TryLock() {
- panic("legacyBackfill() called without locking latestEventBackfillLock")
- }
- log := zerolog.Ctx(ctx).With().Str("action", "legacy backfill").Logger()
- ctx = log.WithContext(ctx)
- conv, err := user.bridge.DB.HistorySync.GetConversation(ctx, user.MXID, portal.Key)
- if err != nil {
- log.Err(err).Msg("Failed to get history sync conversation data for backfill")
- return
- }
- messages, err := user.bridge.DB.HistorySync.GetMessagesBetween(ctx, user.MXID, portal.Key.JID.String(), nil, nil, portal.bridge.Config.Bridge.HistorySync.MessageCount)
- if err != nil {
- log.Err(err).Msg("Failed to get history sync messages for backfill")
- return
- }
- log.Debug().Int("message_count", len(messages)).Msg("Got messages to backfill from database")
- for i := len(messages) - 1; i >= 0; i-- {
- msgEvt, err := user.Client.ParseWebMessage(portal.Key.JID, messages[i])
- if err != nil {
- log.Warn().Err(err).
- Int("msg_index", i).
- Str("msg_id", messages[i].GetKey().GetId()).
- Uint64("msg_time_seconds", messages[i].GetMessageTimestamp()).
- Msg("Dropping historical message due to parse error")
- continue
- }
- ctx := log.With().
- Str("message_id", msgEvt.Info.ID).
- Stringer("message_sender", msgEvt.Info.Sender).
- Logger().
- WithContext(ctx)
- portal.handleMessage(ctx, user, msgEvt, true)
- }
- if conv != nil {
- isUnread := conv.MarkedAsUnread || conv.UnreadCount > 0
- isTooOld := user.bridge.Config.Bridge.HistorySync.UnreadHoursThreshold > 0 && conv.LastMessageTimestamp.Before(time.Now().Add(time.Duration(-user.bridge.Config.Bridge.HistorySync.UnreadHoursThreshold)*time.Hour))
- shouldMarkAsRead := !isUnread || isTooOld
- if shouldMarkAsRead {
- user.markSelfReadFull(ctx, portal)
- }
- }
- log.Info().Msg("Backfill complete, deleting leftover messages from database")
- err = user.bridge.DB.HistorySync.DeleteConversation(ctx, user.MXID, portal.Key.JID.String())
- if err != nil {
- log.Err(err).Msg("Failed to delete history sync conversation from database after backfill")
- }
-}
-
-func (user *User) dailyMediaRequestLoop() {
- log := user.zlog.With().
- Str("action", "daily media request loop").
- Logger()
- ctx := log.WithContext(context.Background())
-
- // Calculate when to do the first set of media retry requests
- now := time.Now()
- userTz, err := time.LoadLocation(user.Timezone)
- tzIsInvalid := err != nil && user.Timezone != ""
- var requestStartTime time.Time
- if tzIsInvalid {
- requestStartTime = now.Add(8 * time.Hour)
- log.Warn().Msg("Invalid time zone, using static 8 hour start time")
- } else {
- if userTz == nil {
- userTz = now.Local().Location()
- }
- tonightMidnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, userTz)
- midnightOffset := time.Duration(user.bridge.Config.Bridge.HistorySync.MediaRequests.RequestLocalTime) * time.Minute
- requestStartTime = tonightMidnight.Add(midnightOffset)
- // If the request time for today has already happened, we need to start the
- // request loop tomorrow instead.
- if requestStartTime.Before(now) {
- requestStartTime = requestStartTime.AddDate(0, 0, 1)
- }
- }
-
- // Wait to start the loop
- log.Info().Time("start_loop_at", requestStartTime).Msg("Waiting until start time to do media retry requests")
- time.Sleep(time.Until(requestStartTime))
-
- for {
- mediaBackfillRequests, err := user.bridge.DB.MediaBackfillRequest.GetMediaBackfillRequestsForUser(ctx, user.MXID)
- if err != nil {
- log.Err(err).Msg("Failed to get media retry requests")
- } else if len(mediaBackfillRequests) > 0 {
- log.Info().Int("media_request_count", len(mediaBackfillRequests)).Msg("Sending media retry requests")
-
- // Send all the media backfill requests for the user at once
- for _, req := range mediaBackfillRequests {
- portal := user.GetPortalByJID(req.PortalKey.JID)
- _, err = portal.requestMediaRetry(ctx, user, req.EventID, req.MediaKey)
- if err != nil {
- log.Err(err).
- Stringer("portal_key", req.PortalKey).
- Stringer("event_id", req.EventID).
- Msg("Failed to send media retry request")
- req.Status = database.MediaBackfillRequestStatusRequestFailed
- req.Error = err.Error()
- } else {
- log.Debug().
- Stringer("portal_key", req.PortalKey).
- Stringer("event_id", req.EventID).
- Msg("Sent media retry request")
- req.Status = database.MediaBackfillRequestStatusRequested
- }
- req.MediaKey = nil
- err = req.Upsert(ctx)
- if err != nil {
- log.Err(err).
- Stringer("portal_key", req.PortalKey).
- Stringer("event_id", req.EventID).
- Msg("Failed to save status of media retry request")
- }
- }
- }
-
- // Wait for 24 hours before making requests again
- time.Sleep(24 * time.Hour)
- }
-}
-
-func (user *User) backfillInChunks(ctx context.Context, req *database.BackfillTask, conv *database.HistorySyncConversation, portal *Portal) {
- portal.backfillLock.Lock()
- defer portal.backfillLock.Unlock()
- log := zerolog.Ctx(ctx)
-
- if len(portal.MXID) > 0 && !user.bridge.AS.StateStore.IsInRoom(ctx, portal.MXID, user.MXID) {
- portal.ensureUserInvited(ctx, user)
- }
-
- backfillState, err := user.bridge.DB.BackfillState.GetBackfillState(ctx, user.MXID, portal.Key)
- if backfillState == nil {
- backfillState = user.bridge.DB.BackfillState.NewBackfillState(user.MXID, portal.Key)
- }
- err = backfillState.SetProcessingBatch(ctx, true)
- if err != nil {
- log.Err(err).Msg("Failed to mark batch as being processed")
- }
- defer func() {
- err = backfillState.SetProcessingBatch(ctx, false)
- if err != nil {
- log.Err(err).Msg("Failed to mark batch as no longer being processed")
- }
- }()
-
- var timeEnd *time.Time
- var forward, shouldMarkAsRead bool
- portal.latestEventBackfillLock.Lock()
- if req.BackfillType == database.BackfillForward {
- // TODO this overrides the TimeStart set when enqueuing the backfill
- // maybe the enqueue should instead include the prev event ID
- lastMessage, err := portal.bridge.DB.Message.GetLastInChat(ctx, portal.Key)
- if err != nil {
- log.Err(err).Msg("Failed to get newest message in chat")
- return
- }
- start := lastMessage.Timestamp.Add(1 * time.Second)
- req.TimeStart = &start
- // Sending events at the end of the room (= latest events)
- forward = true
- } else {
- firstMessage, err := portal.bridge.DB.Message.GetFirstInChat(ctx, portal.Key)
- if err != nil {
- log.Err(err).Msg("Failed to get oldest message in chat")
- return
- }
- if firstMessage != nil {
- end := firstMessage.Timestamp.Add(-1 * time.Second)
- timeEnd = &end
- log.Debug().
- Time("oldest_message_ts", firstMessage.Timestamp).
- Msg("Limiting backfill to messages older than oldest message")
- } else {
- // Portal is empty -> events are latest
- forward = true
- }
- }
- if !forward {
- // We'll use normal batch sending, so no need to keep blocking new message processing
- portal.latestEventBackfillLock.Unlock()
- } else {
- // This might involve sending events at the end of the room as non-historical events,
- // make sure we don't process messages until this is done.
- defer portal.latestEventBackfillLock.Unlock()
-
- isUnread := conv.MarkedAsUnread || conv.UnreadCount > 0
- isTooOld := user.bridge.Config.Bridge.HistorySync.UnreadHoursThreshold > 0 && conv.LastMessageTimestamp.Before(time.Now().Add(time.Duration(-user.bridge.Config.Bridge.HistorySync.UnreadHoursThreshold)*time.Hour))
- shouldMarkAsRead = !isUnread || isTooOld
- }
- allMsgs, err := user.bridge.DB.HistorySync.GetMessagesBetween(ctx, user.MXID, conv.ConversationID, req.TimeStart, timeEnd, req.MaxTotalEvents)
-
- sendDisappearedNotice := false
- // If expired messages are on, and a notice has not been sent to this chat
- // about it having disappeared messages at the conversation timestamp, send
- // a notice indicating so.
- if len(allMsgs) == 0 && conv.EphemeralExpiration != nil && *conv.EphemeralExpiration > 0 {
- lastMessage, err := portal.bridge.DB.Message.GetLastInChat(ctx, portal.Key)
- if err != nil {
- log.Err(err).Msg("Failed to get last message in chat to check if disappeared notice should be sent")
- }
- if lastMessage == nil || conv.LastMessageTimestamp.After(lastMessage.Timestamp) {
- sendDisappearedNotice = true
- }
- }
-
- if !sendDisappearedNotice && len(allMsgs) == 0 {
- log.Debug().Msg("Not backfilling chat: no bridgeable messages found")
- return
- }
-
- if len(portal.MXID) == 0 {
- log.Debug().Msg("Creating portal for chat as part of history sync handling")
- err = portal.CreateMatrixRoom(ctx, user, nil, nil, true, false)
- if err != nil {
- log.Err(err).Msg("Failed to create room for chat during backfill")
- return
- }
- }
-
- // Update the backfill status here after the room has been created.
- portal.updateBackfillStatus(ctx, backfillState)
-
- if sendDisappearedNotice {
- log.Debug().Time("last_message_time", conv.LastMessageTimestamp).
- Msg("Sending notice that there are disappeared messages in the chat")
- resp, err := portal.sendMessage(ctx, portal.MainIntent(), event.EventMessage, &event.MessageEventContent{
- MsgType: event.MsgNotice,
- Body: portal.formatDisappearingMessageNotice(),
- }, nil, conv.LastMessageTimestamp.UnixMilli())
- if err != nil {
- log.Err(err).Msg("Failed to send disappeared messages notice event")
- return
- }
-
- msg := portal.bridge.DB.Message.New()
- msg.Chat = portal.Key
- msg.MXID = resp.EventID
- msg.JID = types.MessageID(resp.EventID)
- msg.Timestamp = conv.LastMessageTimestamp
- msg.SenderMXID = portal.MainIntent().UserID
- msg.Sent = true
- msg.Type = database.MsgFake
- err = msg.Insert(ctx)
- if err != nil {
- log.Err(err).Msg("Failed to save fake message entry for disappearing message timer in backfill")
- }
- user.markSelfReadFull(ctx, portal)
- return
- }
-
- log.Info().
- Int("message_count", len(allMsgs)).
- Int("max_batch_events", req.MaxBatchEvents).
- Msg("Backfilling messages")
- toBackfill := allMsgs[0:]
- for len(toBackfill) > 0 {
- var msgs []*waProto.WebMessageInfo
- if len(toBackfill) <= req.MaxBatchEvents || req.MaxBatchEvents < 0 {
- msgs = toBackfill
- toBackfill = nil
- } else {
- msgs = toBackfill[:req.MaxBatchEvents]
- toBackfill = toBackfill[req.MaxBatchEvents:]
- }
-
- if len(msgs) > 0 {
- time.Sleep(time.Duration(req.BatchDelay) * time.Second)
- log.Debug().Int("batch_message_count", len(msgs)).Msg("Backfilling message batch")
- portal.backfill(ctx, user, msgs, forward, shouldMarkAsRead)
- }
- }
- log.Debug().Int("message_count", len(allMsgs)).Msg("Finished backfilling messages in queue entry")
- err = user.bridge.DB.HistorySync.DeleteMessages(ctx, user.MXID, conv.ConversationID, allMsgs)
- if err != nil {
- log.Err(err).Msg("Failed to delete history sync messages after backfilling")
- }
-
- if req.TimeStart == nil {
- // If the time start is nil, then there's no more history to backfill.
- backfillState.BackfillComplete = true
-
- if conv.EndOfHistoryTransferType == waProto.Conversation_COMPLETE_BUT_MORE_MESSAGES_REMAIN_ON_PRIMARY {
- // Since there are more messages on the phone, but we can't
- // backfill any more of them, indicate that the last timestamp
- // that we expect to be backfilled is the oldest one that was just
- // backfilled.
- backfillState.FirstExpectedTimestamp = allMsgs[len(allMsgs)-1].GetMessageTimestamp()
- } else if conv.EndOfHistoryTransferType == waProto.Conversation_COMPLETE_AND_NO_MORE_MESSAGE_REMAIN_ON_PRIMARY {
- // Since there are no more messages left on the phone, we've
- // backfilled everything. Indicate so by setting the expected
- // timestamp to 0 which means that the backfill goes to the
- // beginning of time.
- backfillState.FirstExpectedTimestamp = 0
- }
- err = backfillState.Upsert(ctx)
- if err != nil {
- log.Err(err).Msg("Failed to mark backfill state as completed in database")
- }
- portal.updateBackfillStatus(ctx, backfillState)
- }
-}
-
-func (user *User) storeHistorySync(evt *waProto.HistorySync) {
- if evt == nil || evt.SyncType == nil {
- return
- }
- log := user.zlog.With().
- Str("method", "User.storeHistorySync").
- Str("sync_type", evt.GetSyncType().String()).
- Uint32("chunk_order", evt.GetChunkOrder()).
- Uint32("progress", evt.GetProgress()).
- Logger()
- ctx := log.WithContext(context.TODO())
- if evt.GetGlobalSettings() != nil {
- log.Debug().Interface("global_settings", evt.GetGlobalSettings()).Msg("Got global settings in history sync")
- }
- if evt.GetSyncType() == waProto.HistorySync_INITIAL_STATUS_V3 || evt.GetSyncType() == waProto.HistorySync_PUSH_NAME || evt.GetSyncType() == waProto.HistorySync_NON_BLOCKING_DATA {
- log.Debug().
- Int("conversation_count", len(evt.GetConversations())).
- Int("pushname_count", len(evt.GetPushnames())).
- Int("status_count", len(evt.GetStatusV3Messages())).
- Int("recent_sticker_count", len(evt.GetRecentStickers())).
- Int("past_participant_count", len(evt.GetPastParticipants())).
- Msg("Ignoring history sync")
- return
- }
- log.Info().
- Int("conversation_count", len(evt.GetConversations())).
- Int("past_participant_count", len(evt.GetPastParticipants())).
- Msg("Storing history sync")
-
- successfullySavedTotal := 0
- failedToSaveTotal := 0
- totalMessageCount := 0
- for _, conv := range evt.GetConversations() {
- 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().Str("chat_jid", jid.String()).Msg("Skipping broadcast list in history sync")
- continue
- } else if jid.Server == types.HiddenUserServer {
- log.Debug().Str("chat_jid", jid.String()).Msg("Skipping hidden user JID chat in history sync")
- continue
- }
- totalMessageCount += len(conv.GetMessages())
- log := log.With().
- Str("chat_jid", jid.String()).
- Int("msg_count", len(conv.GetMessages())).
- Logger()
-
- var portal *Portal
- initPortal := func() {
- if portal != nil {
- return
- }
- portal = user.GetPortalByJID(jid)
- historySyncConversation := user.bridge.DB.HistorySync.NewConversationWithValues(
- user.MXID,
- conv.GetId(),
- portal.Key,
- getConversationTimestamp(conv),
- conv.GetMuteEndTime(),
- conv.GetArchived(),
- conv.GetPinned(),
- conv.GetDisappearingMode().GetInitiator(),
- conv.GetEndOfHistoryTransferType(),
- conv.EphemeralExpiration,
- conv.GetMarkedAsUnread(),
- conv.GetUnreadCount())
- err := historySyncConversation.Upsert(ctx)
- if err != nil {
- log.Err(err).Msg("Failed to insert history sync conversation into database")
- }
- }
-
- var minTime, maxTime time.Time
- var minTimeIndex, maxTimeIndex int
-
- successfullySaved := 0
- failedToSave := 0
- unsupportedTypes := 0
- for i, rawMsg := range conv.GetMessages() {
- // Don't store messages that will just be skipped.
- msgEvt, err := user.Client.ParseWebMessage(jid, rawMsg.GetMessage())
- if err != nil {
- log.Warn().Err(err).
- Int("msg_index", i).
- Str("msg_id", rawMsg.GetMessage().GetKey().GetId()).
- Uint64("msg_time_seconds", rawMsg.GetMessage().GetMessageTimestamp()).
- Msg("Dropping historical message due to parse error")
- continue
- }
- if minTime.IsZero() || msgEvt.Info.Timestamp.Before(minTime) {
- minTime = msgEvt.Info.Timestamp
- minTimeIndex = i
- }
- if maxTime.IsZero() || msgEvt.Info.Timestamp.After(maxTime) {
- maxTime = msgEvt.Info.Timestamp
- maxTimeIndex = i
- }
-
- msgType := getMessageType(msgEvt.Message)
- if msgType == "unknown" || msgType == "ignore" || strings.HasPrefix(msgType, "unknown_protocol_") || !containsSupportedMessage(msgEvt.Message) {
- unsupportedTypes++
- continue
- }
-
- initPortal()
-
- message, err := user.bridge.DB.HistorySync.NewMessageWithValues(user.MXID, conv.GetId(), msgEvt.Info.ID, rawMsg)
- if err != nil {
- log.Error().Err(err).
- Int("msg_index", i).
- Str("msg_id", msgEvt.Info.ID).
- Time("msg_time", msgEvt.Info.Timestamp).
- Msg("Failed to save historical message")
- failedToSave++
- continue
- }
- err = message.Insert(ctx)
- if err != nil {
- log.Error().Err(err).
- Int("msg_index", i).
- Str("msg_id", msgEvt.Info.ID).
- Time("msg_time", msgEvt.Info.Timestamp).
- Msg("Failed to save historical message")
- failedToSave++
- } else {
- successfullySaved++
- }
- }
- successfullySavedTotal += successfullySaved
- failedToSaveTotal += failedToSave
- log.Debug().
- Int("saved_count", successfullySaved).
- Int("failed_count", failedToSave).
- Int("unsupported_msg_type_count", unsupportedTypes).
- Time("lowest_time", minTime).
- Int("lowest_time_index", minTimeIndex).
- Time("highest_time", maxTime).
- Int("highest_time_index", maxTimeIndex).
- Dict("metadata", zerolog.Dict().
- Uint32("ephemeral_expiration", conv.GetEphemeralExpiration()).
- Bool("marked_unread", conv.GetMarkedAsUnread()).
- Bool("archived", conv.GetArchived()).
- Uint32("pinned", conv.GetPinned()).
- Uint64("mute_end", conv.GetMuteEndTime()).
- Uint32("unread_count", conv.GetUnreadCount()),
- ).
- Msg("Saved messages from history sync conversation")
- }
- log.Info().
- Int("total_saved_count", successfullySavedTotal).
- Int("total_failed_count", failedToSaveTotal).
- Int("total_message_count", totalMessageCount).
- Msg("Finished storing history sync")
-
- // If this was the initial bootstrap, enqueue immediate backfills for the
- // most recent portals. If it's the last history sync event, start
- // backfilling the rest of the history of the portals.
- if user.bridge.Config.Bridge.HistorySync.Backfill {
- user.enqueueBackfillsTimer.Reset(EnqueueBackfillsDelay)
- }
-}
-
-func getConversationTimestamp(conv *waProto.Conversation) uint64 {
- convTs := conv.GetConversationTimestamp()
- if convTs == 0 && len(conv.GetMessages()) > 0 {
- convTs = conv.Messages[0].GetMessage().GetMessageTimestamp()
- }
- return convTs
-}
-
-func (user *User) EnqueueImmediateBackfills(ctx context.Context, portals []*Portal) {
- for priority, portal := range portals {
- maxMessages := user.bridge.Config.Bridge.HistorySync.Immediate.MaxEvents
- initialBackfill := user.bridge.DB.BackfillQueue.NewWithValues(user.MXID, database.BackfillImmediate, priority, portal.Key, nil, maxMessages, maxMessages, 0)
- err := initialBackfill.Insert(ctx)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).
- Stringer("portal_key", portal.Key).
- Msg("Failed to insert immediate backfill into database")
- }
- }
-}
-
-func (user *User) EnqueueDeferredBackfills(ctx context.Context, portals []*Portal) {
- numPortals := len(portals)
- for stageIdx, backfillStage := range user.bridge.Config.Bridge.HistorySync.Deferred {
- for portalIdx, portal := range portals {
- var startDate *time.Time = nil
- if backfillStage.StartDaysAgo > 0 {
- startDaysAgo := time.Now().AddDate(0, 0, -backfillStage.StartDaysAgo)
- startDate = &startDaysAgo
- }
- backfillMessages := user.bridge.DB.BackfillQueue.NewWithValues(
- user.MXID, database.BackfillDeferred, stageIdx*numPortals+portalIdx, portal.Key, startDate, backfillStage.MaxBatchEvents, -1, backfillStage.BatchDelay)
- err := backfillMessages.Insert(ctx)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).
- Stringer("portal_key", portal.Key).
- Msg("Failed to insert deferred backfill into database")
- }
- }
- }
-}
-
-func (user *User) EnqueueForwardBackfills(ctx context.Context, portals []*Portal) {
- for priority, portal := range portals {
- lastMsg, err := user.bridge.DB.Message.GetLastInChat(ctx, portal.Key)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).
- Stringer("portal_key", portal.Key).
- Msg("Failed to get last message in chat to enqueue forward backfill")
- } else if lastMsg == nil {
- continue
- }
- backfill := user.bridge.DB.BackfillQueue.NewWithValues(
- user.MXID, database.BackfillForward, priority, portal.Key, &lastMsg.Timestamp, -1, -1, 0)
- err = backfill.Insert(ctx)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).
- Stringer("portal_key", portal.Key).
- Msg("Failed to insert forward backfill into database")
- }
- }
-}
-
-// endregion
-// region Portal backfilling
-
-func (portal *Portal) deterministicEventID(sender types.JID, messageID types.MessageID, partName string) id.EventID {
- data := fmt.Sprintf("%s/whatsapp/%s/%s", portal.MXID, sender.User, messageID)
- if partName != "" {
- data += "/" + partName
- }
- sum := sha256.Sum256([]byte(data))
- return id.EventID(fmt.Sprintf("$%s:whatsapp.com", base64.RawURLEncoding.EncodeToString(sum[:])))
-}
-
-var (
- BackfillStatusEvent = event.Type{Type: "com.beeper.backfill_status", Class: event.StateEventType}
-)
-
-func (portal *Portal) backfill(ctx context.Context, source *User, messages []*waProto.WebMessageInfo, isForward, atomicMarkAsRead bool) {
- log := zerolog.Ctx(ctx)
- var req mautrix.ReqBeeperBatchSend
- var infos []*wrappedInfo
-
- req.Forward = isForward
- if atomicMarkAsRead {
- req.MarkReadBy = source.MXID
- }
-
- log.Info().
- Bool("forward", isForward).
- Int("message_count", len(messages)).
- Msg("Processing history sync message batch")
- // The messages are ordered newest to oldest, so iterate them in reverse order.
- for i := len(messages) - 1; i >= 0; i-- {
- webMsg := messages[i]
- msgEvt, err := source.Client.ParseWebMessage(portal.Key.JID, webMsg)
- if err != nil {
- continue
- }
- log := log.With().
- Str("message_id", msgEvt.Info.ID).
- Stringer("message_sender", msgEvt.Info.Sender).
- Logger()
- ctx := log.WithContext(ctx)
-
- msgType := getMessageType(msgEvt.Message)
- if msgType == "unknown" || msgType == "ignore" || msgType == "unknown_protocol" {
- if msgType != "ignore" {
- log.Debug().Msg("Skipping message with unknown type in backfill")
- }
- continue
- }
- if webMsg.GetPushName() != "" && webMsg.GetPushName() != "-" {
- existingContact, _ := source.Client.Store.Contacts.GetContact(msgEvt.Info.Sender)
- if !existingContact.Found || existingContact.PushName == "" {
- changed, _, err := source.Client.Store.Contacts.PutPushName(msgEvt.Info.Sender, webMsg.GetPushName())
- if err != nil {
- log.Err(err).Msg("Failed to save push name from historical message to device store")
- } else if changed {
- log.Debug().Str("push_name", webMsg.GetPushName()).Msg("Got push name from historical message")
- }
- }
- }
- puppet := portal.getMessagePuppet(ctx, source, &msgEvt.Info)
- if puppet == nil {
- continue
- }
-
- converted := portal.convertMessage(ctx, puppet.IntentFor(portal), source, &msgEvt.Info, msgEvt.Message, true)
- if converted == nil {
- log.Debug().Msg("Skipping unsupported message in backfill")
- continue
- }
- if converted.ReplyTo != nil {
- portal.SetReply(ctx, converted.Content, converted.ReplyTo, true)
- }
- err = portal.appendBatchEvents(ctx, source, converted, &msgEvt.Info, webMsg, &req.Events, &infos)
- if err != nil {
- log.Err(err).Msg("Failed to handle message in backfill")
- }
- }
- log.Info().Int("event_count", len(req.Events)).Msg("Made Matrix events from messages in batch")
-
- if len(req.Events) == 0 {
- return
- }
-
- resp, err := portal.MainIntent().BeeperBatchSend(ctx, portal.MXID, &req)
- if err != nil {
- log.Err(err).Msg("Failed to send batch of messages")
- return
- }
- err = portal.bridge.DB.DoTxn(ctx, nil, func(ctx context.Context) error {
- return portal.finishBatch(ctx, resp.EventIDs, infos)
- })
- if err != nil {
- log.Err(err).Msg("Failed to save message batch to database")
- return
- }
- log.Info().Msg("Successfully sent backfill batch")
- if portal.bridge.Config.Bridge.HistorySync.MediaRequests.AutoRequestMedia {
- go portal.requestMediaRetries(context.TODO(), source, resp.EventIDs, infos)
- }
-}
-
-func (portal *Portal) requestMediaRetries(ctx context.Context, source *User, eventIDs []id.EventID, infos []*wrappedInfo) {
- for i, info := range infos {
- if info != nil && info.Error == database.MsgErrMediaNotFound && info.MediaKey != nil {
- switch portal.bridge.Config.Bridge.HistorySync.MediaRequests.RequestMethod {
- case config.MediaRequestMethodImmediate:
- err := source.Client.SendMediaRetryReceipt(info.MessageInfo, info.MediaKey)
- if err != nil {
- portal.zlog.Err(err).Str("message_id", info.ID).Msg("Failed to send post-backfill media retry request")
- } else {
- portal.zlog.Debug().Str("message_id", info.ID).Msg("Sent post-backfill media retry request")
- }
- case config.MediaRequestMethodLocalTime:
- req := portal.bridge.DB.MediaBackfillRequest.NewMediaBackfillRequestWithValues(source.MXID, portal.Key, eventIDs[i], info.MediaKey)
- err := req.Upsert(ctx)
- if err != nil {
- portal.zlog.Err(err).
- Stringer("event_id", eventIDs[i]).
- Msg("Failed to upsert media backfill request")
- }
- }
- }
- }
-}
-
-func (portal *Portal) appendBatchEvents(ctx context.Context, source *User, converted *ConvertedMessage, info *types.MessageInfo, raw *waProto.WebMessageInfo, eventsArray *[]*event.Event, infoArray *[]*wrappedInfo) error {
- if portal.bridge.Config.Bridge.CaptionInMessage {
- converted.MergeCaption()
- }
- mainEvt, err := portal.wrapBatchEvent(ctx, info, converted.Intent, converted.Type, converted.Content, converted.Extra, "")
- if err != nil {
- return err
- }
- expirationStart := info.Timestamp
- if raw.GetEphemeralStartTimestamp() > 0 {
- expirationStart = time.Unix(int64(raw.GetEphemeralStartTimestamp()), 0)
- }
- mainInfo := &wrappedInfo{
- MessageInfo: info,
- Type: database.MsgNormal,
- SenderMXID: mainEvt.Sender,
- Error: converted.Error,
- MediaKey: converted.MediaKey,
- ExpirationStart: expirationStart,
- ExpiresIn: converted.ExpiresIn,
- }
- if converted.Caption != nil {
- captionEvt, err := portal.wrapBatchEvent(ctx, info, converted.Intent, converted.Type, converted.Caption, nil, "caption")
- if err != nil {
- return err
- }
- *eventsArray = append(*eventsArray, mainEvt, captionEvt)
- *infoArray = append(*infoArray, mainInfo, nil)
- } else {
- *eventsArray = append(*eventsArray, mainEvt)
- *infoArray = append(*infoArray, mainInfo)
- }
- if converted.MultiEvent != nil {
- for i, subEvtContent := range converted.MultiEvent {
- subEvt, err := portal.wrapBatchEvent(ctx, info, converted.Intent, converted.Type, subEvtContent, nil, fmt.Sprintf("multi-%d", i))
- if err != nil {
- return err
- }
- *eventsArray = append(*eventsArray, subEvt)
- *infoArray = append(*infoArray, nil)
- }
- }
- for _, reaction := range raw.GetReactions() {
- reactionEvent, reactionInfo := portal.wrapBatchReaction(ctx, source, reaction, mainEvt.ID, info.Timestamp)
- if reactionEvent != nil {
- *eventsArray = append(*eventsArray, reactionEvent)
- *infoArray = append(*infoArray, &wrappedInfo{
- MessageInfo: reactionInfo,
- SenderMXID: reactionEvent.Sender,
- ReactionTarget: info.ID,
- Type: database.MsgReaction,
- })
- }
- }
- return nil
-}
-
-func (portal *Portal) wrapBatchReaction(ctx context.Context, source *User, reaction *waProto.Reaction, mainEventID id.EventID, mainEventTS time.Time) (reactionEvent *event.Event, reactionInfo *types.MessageInfo) {
- var senderJID types.JID
- if reaction.GetKey().GetFromMe() {
- senderJID = source.JID.ToNonAD()
- } else if reaction.GetKey().GetParticipant() != "" {
- senderJID, _ = types.ParseJID(reaction.GetKey().GetParticipant())
- } else if portal.IsPrivateChat() {
- senderJID = portal.Key.JID
- }
- if senderJID.IsEmpty() {
- return
- }
- reactionInfo = &types.MessageInfo{
- MessageSource: types.MessageSource{
- Chat: portal.Key.JID,
- Sender: senderJID,
- IsFromMe: reaction.GetKey().GetFromMe(),
- IsGroup: portal.IsGroupChat(),
- },
- ID: reaction.GetKey().GetId(),
- Timestamp: mainEventTS,
- }
- puppet := portal.getMessagePuppet(ctx, source, reactionInfo)
- if puppet == nil {
- return
- }
- intent := puppet.IntentFor(portal)
- content := event.ReactionEventContent{
- RelatesTo: event.RelatesTo{
- Type: event.RelAnnotation,
- EventID: mainEventID,
- Key: variationselector.Add(reaction.GetText()),
- },
- }
- if rawTS := reaction.GetSenderTimestampMS(); rawTS >= mainEventTS.UnixMilli() && rawTS <= time.Now().UnixMilli() {
- reactionInfo.Timestamp = time.UnixMilli(rawTS)
- }
- wrappedContent := event.Content{Parsed: &content}
- intent.AddDoublePuppetValue(&wrappedContent)
- reactionEvent = &event.Event{
- ID: portal.deterministicEventID(senderJID, reactionInfo.ID, ""),
- Type: event.EventReaction,
- Content: wrappedContent,
- Sender: intent.UserID,
- Timestamp: reactionInfo.Timestamp.UnixMilli(),
- }
- return
-}
-
-func (portal *Portal) wrapBatchEvent(ctx context.Context, info *types.MessageInfo, intent *appservice.IntentAPI, eventType event.Type, content *event.MessageEventContent, extraContent map[string]interface{}, partName string) (*event.Event, error) {
- wrappedContent := event.Content{
- Parsed: content,
- Raw: extraContent,
- }
- newEventType, err := portal.encrypt(ctx, intent, &wrappedContent, eventType)
- if err != nil {
- return nil, err
- }
- intent.AddDoublePuppetValue(&wrappedContent)
- return &event.Event{
- ID: portal.deterministicEventID(info.Sender, info.ID, partName),
- Sender: intent.UserID,
- Type: newEventType,
- Timestamp: info.Timestamp.UnixMilli(),
- Content: wrappedContent,
- }, nil
-}
-
-func (portal *Portal) finishBatch(ctx context.Context, eventIDs []id.EventID, infos []*wrappedInfo) error {
- for i, info := range infos {
- if info == nil {
- continue
- }
-
- eventID := eventIDs[i]
- portal.markHandled(ctx, nil, info.MessageInfo, eventID, info.SenderMXID, true, false, info.Type, 0, info.Error)
- if info.Type == database.MsgReaction {
- portal.upsertReaction(ctx, nil, info.ReactionTarget, info.Sender, eventID, info.ID)
- }
-
- if info.ExpiresIn > 0 {
- portal.MarkDisappearing(ctx, eventID, info.ExpiresIn, info.ExpirationStart)
- }
- }
- return nil
-}
-
-func (portal *Portal) updateBackfillStatus(ctx context.Context, backfillState *database.BackfillState) {
- backfillStatus := "backfilling"
- if backfillState.BackfillComplete {
- backfillStatus = "complete"
- }
-
- _, err := portal.bridge.Bot.SendStateEvent(ctx, portal.MXID, BackfillStatusEvent, "", map[string]interface{}{
- "status": backfillStatus,
- "first_timestamp": backfillState.FirstExpectedTimestamp * 1000,
- })
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to send backfill status event to room")
- }
-}
-
-// endregion
diff --git a/main.go b/main.go
deleted file mode 100644
index 4b90bdf..0000000
--- a/main.go
+++ /dev/null
@@ -1,279 +0,0 @@
-// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
-// Copyright (C) 2022 Tulir Asokan
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package main
-
-import (
- "context"
- _ "embed"
- "net/url"
- "os"
- "strconv"
- "strings"
- "sync"
- "time"
-
- "github.com/rs/zerolog"
- "go.mau.fi/whatsmeow/proto/waCompanionReg"
- waLog "go.mau.fi/whatsmeow/util/log"
- "google.golang.org/protobuf/proto"
-
- "go.mau.fi/whatsmeow"
- waProto "go.mau.fi/whatsmeow/binary/proto"
- "go.mau.fi/whatsmeow/store"
- "go.mau.fi/whatsmeow/store/sqlstore"
- "go.mau.fi/whatsmeow/types"
-
- "go.mau.fi/util/configupgrade"
-
- "maunium.net/go/mautrix/bridge"
- "maunium.net/go/mautrix/bridge/commands"
- "maunium.net/go/mautrix/bridge/status"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/id"
-
- "maunium.net/go/mautrix-whatsapp/config"
- "maunium.net/go/mautrix-whatsapp/database"
-)
-
-// Information to find out exactly which commit the bridge was built from.
-// These are filled at build time with the -X linker flag.
-var (
- Tag = "unknown"
- Commit = "unknown"
- BuildTime = "unknown"
-)
-
-//go:embed example-config.yaml
-var ExampleConfig string
-
-type WABridge struct {
- bridge.Bridge
- Config *config.Config
- DB *database.Database
- Provisioning *ProvisioningAPI
- Formatter *Formatter
- Metrics *MetricsHandler
- WAContainer *sqlstore.Container
- WAVersion string
-
- usersByMXID map[id.UserID]*User
- usersByUsername map[string]*User
- usersLock sync.Mutex
- spaceRooms map[id.RoomID]*User
- spaceRoomsLock sync.Mutex
- managementRooms map[id.RoomID]*User
- managementRoomsLock sync.Mutex
- portalsByMXID map[id.RoomID]*Portal
- portalsByJID map[database.PortalKey]*Portal
- portalsLock sync.Mutex
- puppets map[types.JID]*Puppet
- puppetsByCustomMXID map[id.UserID]*Puppet
- puppetsLock sync.Mutex
-}
-
-func (br *WABridge) Init() {
- br.CommandProcessor = commands.NewProcessor(&br.Bridge)
- br.RegisterCommands()
-
- // TODO this is a weird place for this
- br.EventProcessor.On(event.EphemeralEventPresence, br.HandlePresence)
- br.EventProcessor.On(TypeMSC3381PollStart, br.MatrixHandler.HandleMessage)
- br.EventProcessor.On(TypeMSC3381PollResponse, br.MatrixHandler.HandleMessage)
- br.EventProcessor.On(TypeMSC3381V2PollResponse, br.MatrixHandler.HandleMessage)
-
- Analytics.log = br.ZLog.With().Str("component", "analytics").Logger()
- Analytics.url = (&url.URL{
- Scheme: "https",
- Host: br.Config.Analytics.Host,
- Path: "/v1/track",
- }).String()
- Analytics.key = br.Config.Analytics.Token
- Analytics.userID = br.Config.Analytics.UserID
- if Analytics.IsEnabled() {
- Analytics.log.Info().Str("override_user_id", Analytics.userID).Msg("Analytics metrics are enabled")
- }
-
- br.DB = database.New(br.Bridge.DB)
- br.WAContainer = sqlstore.NewWithDB(br.DB.RawDB, br.DB.Dialect.String(), waLog.Zerolog(br.ZLog.With().Str("db_section", "whatsmeow").Logger()))
- br.WAContainer.DatabaseErrorHandler = br.DB.HandleSignalStoreError
-
- ss := br.Config.Bridge.Provisioning.SharedSecret
- if len(ss) > 0 && ss != "disable" {
- br.Provisioning = &ProvisioningAPI{bridge: br, log: br.ZLog.With().Str("component", "provisioning").Logger()}
- }
-
- br.Formatter = NewFormatter(br)
- br.Metrics = NewMetricsHandler(br.Config.Metrics.Listen, br.ZLog.With().Str("component", "metrics").Logger(), br.DB)
- br.MatrixHandler.TrackEventDuration = br.Metrics.TrackMatrixEvent
-
- store.BaseClientPayload.UserAgent.OsVersion = proto.String(br.WAVersion)
- store.BaseClientPayload.UserAgent.OsBuildNumber = proto.String(br.WAVersion)
- store.DeviceProps.Os = proto.String(br.Config.WhatsApp.OSName)
- store.DeviceProps.RequireFullSync = proto.Bool(br.Config.Bridge.HistorySync.RequestFullSync)
- if fsc := br.Config.Bridge.HistorySync.FullSyncConfig; fsc.DaysLimit > 0 && fsc.SizeLimit > 0 && fsc.StorageQuota > 0 {
- store.DeviceProps.HistorySyncConfig = &waProto.DeviceProps_HistorySyncConfig{
- FullSyncDaysLimit: proto.Uint32(fsc.DaysLimit),
- FullSyncSizeMbLimit: proto.Uint32(fsc.SizeLimit),
- StorageQuotaMb: proto.Uint32(fsc.StorageQuota),
- }
- }
- versionParts := strings.Split(br.WAVersion, ".")
- if len(versionParts) > 2 {
- primary, _ := strconv.Atoi(versionParts[0])
- secondary, _ := strconv.Atoi(versionParts[1])
- tertiary, _ := strconv.Atoi(versionParts[2])
- store.DeviceProps.Version.Primary = proto.Uint32(uint32(primary))
- store.DeviceProps.Version.Secondary = proto.Uint32(uint32(secondary))
- store.DeviceProps.Version.Tertiary = proto.Uint32(uint32(tertiary))
- }
- platformID, ok := waCompanionReg.DeviceProps_PlatformType_value[strings.ToUpper(br.Config.WhatsApp.BrowserName)]
- if ok {
- store.DeviceProps.PlatformType = waProto.DeviceProps_PlatformType(platformID).Enum()
- }
-}
-
-func (br *WABridge) Start() {
- err := br.WAContainer.Upgrade()
- if err != nil {
- br.ZLog.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to upgrade whatsmeow database")
- os.Exit(15)
- }
- if br.Provisioning != nil {
- br.Provisioning.Init()
- }
- // TODO find out how the new whatsapp version checks for updates
- ver, err := whatsmeow.GetLatestVersion(br.AS.HTTPClient)
- if err != nil {
- br.ZLog.Err(err).Msg("Failed to get latest WhatsApp web version number")
- } else {
- br.ZLog.Debug().
- Stringer("hardcoded_version", store.GetWAVersion()).
- Stringer("latest_version", *ver).
- Msg("Got latest WhatsApp web version number")
- store.SetWAVersion(*ver)
- }
- br.WaitWebsocketConnected()
- go br.StartUsers()
- if br.Config.Metrics.Enabled {
- go br.Metrics.Start()
- }
-
- go br.Loop()
-}
-
-func (br *WABridge) Loop() {
- ctx := br.ZLog.With().Str("action", "background loop").Logger().WithContext(context.TODO())
- for {
- br.SleepAndDeleteUpcoming(ctx)
- time.Sleep(1 * time.Hour)
- br.WarnUsersAboutDisconnection()
- }
-}
-
-func (br *WABridge) WarnUsersAboutDisconnection() {
- br.usersLock.Lock()
- for _, user := range br.usersByUsername {
- if user.IsConnected() && !user.PhoneRecentlySeen(true) {
- go user.sendPhoneOfflineWarning(context.TODO())
- }
- }
- br.usersLock.Unlock()
-}
-
-func (br *WABridge) StartUsers() {
- br.ZLog.Debug().Msg("Starting users")
- foundAnySessions := false
- for _, user := range br.GetAllUsers() {
- if !user.JID.IsEmpty() {
- foundAnySessions = true
- }
- go user.Connect()
- }
- if !foundAnySessions {
- br.SendGlobalBridgeState(status.BridgeState{StateEvent: status.StateUnconfigured}.Fill(nil))
- }
- br.ZLog.Debug().Msg("Starting custom puppets")
- for _, loopuppet := range br.GetAllPuppetsWithCustomMXID() {
- go func(puppet *Puppet) {
- puppet.zlog.Debug().Stringer("custom_mxid", puppet.CustomMXID).Msg("Starting double puppet")
- err := puppet.StartCustomMXID(true)
- if err != nil {
- puppet.zlog.Err(err).Stringer("custom_mxid", puppet.CustomMXID).Msg("Failed to start double puppet")
- }
- }(loopuppet)
- }
-}
-
-func (br *WABridge) Stop() {
- br.Metrics.Stop()
- for _, user := range br.usersByUsername {
- if user.Client == nil {
- continue
- }
- user.zlog.Debug().Msg("Disconnecting user")
- user.Client.Disconnect()
- close(user.historySyncs)
- }
-}
-
-func (br *WABridge) GetExampleConfig() string {
- return ExampleConfig
-}
-
-func (br *WABridge) GetConfigPtr() interface{} {
- br.Config = &config.Config{
- BaseConfig: &br.Bridge.Config,
- }
- br.Config.BaseConfig.Bridge = &br.Config.Bridge
- return br.Config
-}
-
-func main() {
- br := &WABridge{
- usersByMXID: make(map[id.UserID]*User),
- usersByUsername: make(map[string]*User),
- spaceRooms: make(map[id.RoomID]*User),
- managementRooms: make(map[id.RoomID]*User),
- portalsByMXID: make(map[id.RoomID]*Portal),
- portalsByJID: make(map[database.PortalKey]*Portal),
- puppets: make(map[types.JID]*Puppet),
- puppetsByCustomMXID: make(map[id.UserID]*Puppet),
- }
- br.Bridge = bridge.Bridge{
- Name: "mautrix-whatsapp",
- URL: "https://github.com/mautrix/whatsapp",
- Description: "A Matrix-WhatsApp puppeting bridge.",
- Version: "0.10.9",
- ProtocolName: "WhatsApp",
- BeeperServiceName: "whatsapp",
- BeeperNetworkName: "whatsapp",
-
- CryptoPickleKey: "maunium.net/go/mautrix-whatsapp",
-
- ConfigUpgrader: &configupgrade.StructUpgrader{
- SimpleUpgrader: configupgrade.SimpleUpgrader(config.DoUpgrade),
- Blocks: config.SpacedBlocks,
- Base: ExampleConfig,
- },
-
- Child: br,
- }
- br.InitVersion(Tag, Commit, BuildTime)
- br.WAVersion = strings.FieldsFunc(br.Version, func(r rune) bool { return r == '-' || r == '+' })[0]
-
- br.Main()
-}
diff --git a/matrix.go b/matrix.go
deleted file mode 100644
index 38fafb8..0000000
--- a/matrix.go
+++ /dev/null
@@ -1,147 +0,0 @@
-// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
-// Copyright (C) 2024 Tulir Asokan
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package main
-
-import (
- "context"
- "fmt"
-
- "github.com/rs/zerolog"
- "go.mau.fi/whatsmeow/types"
-
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/bridge"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/format"
- "maunium.net/go/mautrix/id"
-
- "maunium.net/go/mautrix-whatsapp/database"
-)
-
-func (br *WABridge) CreatePrivatePortal(roomID id.RoomID, brInviter bridge.User, brGhost bridge.Ghost) {
- inviter := brInviter.(*User)
- puppet := brGhost.(*Puppet)
- key := database.NewPortalKey(puppet.JID, inviter.JID)
- portal := br.GetPortalByJID(key)
- log := br.ZLog.With().
- Str("action", "create private portal").
- Stringer("target_room_id", roomID).
- Stringer("inviter_mxid", inviter.MXID).
- Stringer("invitee_jid", puppet.JID).
- Logger()
- ctx := log.WithContext(context.TODO())
-
- if len(portal.MXID) == 0 {
- br.createPrivatePortalFromInvite(ctx, roomID, inviter, puppet, portal)
- return
- }
-
- ok := portal.ensureUserInvited(ctx, inviter)
- if !ok {
- log.Warn().Msg("Failed to invite user to existing private chat portal. Redirecting portal to new room...")
- br.createPrivatePortalFromInvite(ctx, roomID, inviter, puppet, portal)
- return
- }
- intent := puppet.DefaultIntent()
- errorMessage := fmt.Sprintf("You already have a private chat portal with me at [%s](%s)", portal.MXID, portal.MXID.URI(br.Config.Homeserver.Domain).MatrixToURL())
- errorContent := format.RenderMarkdown(errorMessage, true, false)
- _, _ = intent.SendMessageEvent(ctx, roomID, event.EventMessage, errorContent)
- log.Debug().Msg("Leaving private chat room from invite as we already have chat with the user")
- _, _ = intent.LeaveRoom(ctx, roomID)
-}
-
-func (br *WABridge) createPrivatePortalFromInvite(ctx context.Context, roomID id.RoomID, inviter *User, puppet *Puppet, portal *Portal) {
- log := zerolog.Ctx(ctx)
- // TODO check if room is already encrypted
- var existingEncryption event.EncryptionEventContent
- var encryptionEnabled bool
- err := portal.MainIntent().StateEvent(ctx, roomID, event.StateEncryption, "", &existingEncryption)
- if err != nil {
- log.Err(err).Msg("Failed to check if encryption is enabled")
- } else {
- encryptionEnabled = existingEncryption.Algorithm == id.AlgorithmMegolmV1
- }
- portal.MXID = roomID
- portal.updateLogger()
- portal.Topic = PrivateChatTopic
- portal.Name = puppet.Displayname
- portal.AvatarURL = puppet.AvatarURL
- portal.Avatar = puppet.Avatar
- log.Info().Msg("Created private chat portal from invite")
- intent := puppet.DefaultIntent()
-
- if br.Config.Bridge.Encryption.Default || encryptionEnabled {
- _, err = intent.InviteUser(ctx, roomID, &mautrix.ReqInviteUser{UserID: br.Bot.UserID})
- if err != nil {
- log.Err(err).Msg("Failed to invite bridge bot to enable e2be")
- }
- err = br.Bot.EnsureJoined(ctx, roomID)
- if err != nil {
- log.Err(err).Msg("Failed to join as bridge bot to enable e2be")
- }
- if !encryptionEnabled {
- _, err = intent.SendStateEvent(ctx, roomID, event.StateEncryption, "", portal.GetEncryptionEventContent())
- if err != nil {
- log.Err(err).Msg("Failed to enable e2be")
- }
- }
- br.AS.StateStore.SetMembership(ctx, roomID, inviter.MXID, event.MembershipJoin)
- br.AS.StateStore.SetMembership(ctx, roomID, puppet.MXID, event.MembershipJoin)
- br.AS.StateStore.SetMembership(ctx, roomID, br.Bot.UserID, event.MembershipJoin)
- portal.Encrypted = true
- }
- _, _ = portal.MainIntent().SetRoomTopic(ctx, portal.MXID, portal.Topic)
- if portal.shouldSetDMRoomMetadata() {
- _, err = portal.MainIntent().SetRoomName(ctx, portal.MXID, portal.Name)
- portal.NameSet = err == nil
- _, err = portal.MainIntent().SetRoomAvatar(ctx, portal.MXID, portal.AvatarURL)
- portal.AvatarSet = err == nil
- }
- err = portal.Update(ctx)
- if err != nil {
- log.Err(err).Msg("Failed to save portal to database after creating from invite")
- }
- portal.UpdateBridgeInfo(ctx)
- _, _ = intent.SendNotice(ctx, roomID, "Private chat portal created")
-}
-
-func (br *WABridge) HandlePresence(ctx context.Context, evt *event.Event) {
- user := br.GetUserByMXIDIfExists(evt.Sender)
- if user == nil || !user.IsLoggedIn() {
- return
- }
- customPuppet := br.GetPuppetByCustomMXID(user.MXID)
- // TODO move this flag to the user and/or portal data
- if customPuppet != nil && !customPuppet.EnablePresence {
- return
- }
-
- presence := types.PresenceAvailable
- if evt.Content.AsPresence().Presence != event.PresenceOnline {
- presence = types.PresenceUnavailable
- user.zlog.Debug().Msg("Marking offline")
- } else {
- user.zlog.Debug().Msg("Marking online")
- }
- user.lastPresence = presence
- if user.Client.Store.PushName != "" {
- err := user.Client.SendPresence(presence)
- if err != nil {
- user.zlog.Err(err).Msg("Failed to set presence")
- }
- }
-}
diff --git a/messagetracking.go b/messagetracking.go
deleted file mode 100644
index 9bac41e..0000000
--- a/messagetracking.go
+++ /dev/null
@@ -1,322 +0,0 @@
-// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
-// Copyright (C) 2024 Tulir Asokan
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package main
-
-import (
- "context"
- "errors"
- "fmt"
- "sync"
- "time"
-
- "github.com/rs/zerolog"
-
- "go.mau.fi/whatsmeow"
-
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/bridge/status"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/id"
-)
-
-var (
- errUserNotConnected = errors.New("you are not connected to WhatsApp")
- errDifferentUser = errors.New("user is not the recipient of this private chat portal")
- errUserNotLoggedIn = errors.New("user is not logged in and chat has no relay bot")
- errRelaybotNotLoggedIn = errors.New("neither user nor relay bot of chat are logged in")
- errMNoticeDisabled = errors.New("bridging m.notice messages is disabled")
- errUnexpectedParsedContentType = errors.New("unexpected parsed content type")
- errInvalidGeoURI = errors.New("invalid `geo:` URI in message")
- errUnknownMsgType = errors.New("unknown msgtype")
- errMediaDownloadFailed = errors.New("failed to download media")
- errMediaDecryptFailed = errors.New("failed to decrypt media")
- errMediaConvertFailed = errors.New("failed to convert media")
- errMediaWhatsAppUploadFailed = errors.New("failed to upload media to WhatsApp")
- errMediaUnsupportedType = errors.New("unsupported media type")
- errTargetNotFound = errors.New("target event not found")
- errReactionDatabaseNotFound = errors.New("reaction database entry not found")
- errReactionTargetNotFound = errors.New("reaction target message not found")
- errTargetIsFake = errors.New("target is a fake event")
- errReactionSentBySomeoneElse = errors.New("target reaction was sent by someone else")
- errDMSentByOtherUser = errors.New("target message was sent by the other user in a DM")
- errPollMissingQuestion = errors.New("poll message is missing question")
- errPollDuplicateOption = errors.New("poll options must be unique")
-
- errGalleryRelay = errors.New("can't send gallery through relay user")
- errGalleryCaption = errors.New("can't send gallery with caption")
-
- errEditUnknownTarget = errors.New("unknown edit target message")
- errEditUnknownTargetType = errors.New("unsupported edited message type")
- errEditDifferentSender = errors.New("can't edit message sent by another user")
- errEditTooOld = errors.New("message is too old to be edited")
-
- errBroadcastReactionNotSupported = errors.New("reacting to status messages is not currently supported")
- errBroadcastSendDisabled = errors.New("sending status messages is disabled")
-
- errMessageDisconnected = &whatsmeow.DisconnectedError{Action: "message send"}
- errMessageRetryDisconnected = &whatsmeow.DisconnectedError{Action: "message send (retry)"}
-
- errMessageTakingLong = errors.New("bridging the message is taking longer than usual")
- errTimeoutBeforeHandling = errors.New("message timed out before handling was started")
-)
-
-func errorToStatusReason(err error) (reason event.MessageStatusReason, status event.MessageStatus, isCertain, sendNotice bool, humanMessage string) {
- switch {
- case errors.Is(err, whatsmeow.ErrBroadcastListUnsupported),
- errors.Is(err, errUnexpectedParsedContentType),
- errors.Is(err, errUnknownMsgType),
- errors.Is(err, errInvalidGeoURI),
- errors.Is(err, whatsmeow.ErrUnknownServer),
- errors.Is(err, whatsmeow.ErrRecipientADJID),
- errors.Is(err, errBroadcastReactionNotSupported),
- errors.Is(err, errBroadcastSendDisabled):
- return event.MessageStatusUnsupported, event.MessageStatusFail, true, true, ""
- case errors.Is(err, errMNoticeDisabled):
- return event.MessageStatusUnsupported, event.MessageStatusFail, true, false, ""
- case errors.Is(err, errMediaUnsupportedType),
- errors.Is(err, errPollMissingQuestion),
- errors.Is(err, errPollDuplicateOption),
- errors.Is(err, errEditDifferentSender),
- errors.Is(err, errEditTooOld),
- errors.Is(err, errEditUnknownTarget),
- errors.Is(err, errEditUnknownTargetType):
- return event.MessageStatusUnsupported, event.MessageStatusFail, true, true, err.Error()
- case errors.Is(err, errTimeoutBeforeHandling):
- return event.MessageStatusTooOld, event.MessageStatusRetriable, true, true, "the message was too old when it reached the bridge, so it was not handled"
- case errors.Is(err, context.DeadlineExceeded):
- return event.MessageStatusTooOld, event.MessageStatusRetriable, false, true, "handling the message took too long and was cancelled"
- case errors.Is(err, errMessageTakingLong):
- return event.MessageStatusTooOld, event.MessageStatusPending, false, true, err.Error()
- case errors.Is(err, errTargetNotFound),
- errors.Is(err, errTargetIsFake),
- errors.Is(err, errReactionDatabaseNotFound),
- errors.Is(err, errReactionTargetNotFound),
- errors.Is(err, errReactionSentBySomeoneElse),
- errors.Is(err, errDMSentByOtherUser):
- return event.MessageStatusGenericError, event.MessageStatusFail, true, false, ""
- case errors.Is(err, whatsmeow.ErrNotConnected),
- errors.Is(err, errUserNotConnected):
- return event.MessageStatusGenericError, event.MessageStatusRetriable, true, true, ""
- case errors.Is(err, errUserNotLoggedIn),
- errors.Is(err, errDifferentUser),
- errors.Is(err, errRelaybotNotLoggedIn):
- return event.MessageStatusGenericError, event.MessageStatusRetriable, true, false, ""
- case errors.Is(err, errMessageDisconnected),
- errors.Is(err, errMessageRetryDisconnected):
- return event.MessageStatusGenericError, event.MessageStatusRetriable, false, true, ""
- default:
- return event.MessageStatusGenericError, event.MessageStatusRetriable, false, true, ""
- }
-}
-
-func (portal *Portal) sendErrorMessage(ctx context.Context, evt *event.Event, err error, confirmed bool, editID id.EventID) id.EventID {
- if !portal.bridge.Config.Bridge.MessageErrorNotices {
- return ""
- }
- certainty := "may not have been"
- if confirmed {
- certainty = "was not"
- }
- var msgType string
- switch evt.Type {
- case event.EventMessage:
- msgType = "message"
- case event.EventReaction:
- msgType = "reaction"
- case event.EventRedaction:
- msgType = "redaction"
- case TypeMSC3381PollResponse, TypeMSC3381V2PollResponse:
- msgType = "poll response"
- case TypeMSC3381PollStart:
- msgType = "poll start"
- default:
- msgType = "unknown event"
- }
- msg := fmt.Sprintf("\u26a0 Your %s %s bridged: %v", msgType, certainty, err)
- if errors.Is(err, errMessageTakingLong) {
- msg = fmt.Sprintf("\u26a0 Bridging your %s is taking longer than usual", msgType)
- }
- content := &event.MessageEventContent{
- MsgType: event.MsgNotice,
- Body: msg,
- }
- if editID != "" {
- content.SetEdit(editID)
- } else {
- content.SetReply(evt)
- }
- resp, err := portal.sendMainIntentMessage(ctx, content)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to send bridging error message")
- return ""
- }
- return resp.EventID
-}
-
-func (portal *Portal) sendStatusEvent(ctx context.Context, evtID, lastRetry id.EventID, err error, deliveredTo *[]id.UserID) {
- if !portal.bridge.Config.Bridge.MessageStatusEvents {
- return
- }
- if lastRetry == evtID {
- lastRetry = ""
- }
- intent := portal.bridge.Bot
- if !portal.Encrypted {
- // Bridge bot isn't present in unencrypted DMs
- intent = portal.MainIntent()
- }
- content := event.BeeperMessageStatusEventContent{
- Network: portal.getBridgeInfoStateKey(),
- RelatesTo: event.RelatesTo{
- Type: event.RelReference,
- EventID: evtID,
- },
- DeliveredToUsers: deliveredTo,
- LastRetry: lastRetry,
- }
- if err == nil {
- content.Status = event.MessageStatusSuccess
- } else {
- content.Reason, content.Status, _, _, content.Message = errorToStatusReason(err)
- content.Error = err.Error()
- }
- _, err = intent.SendMessageEvent(ctx, portal.MXID, event.BeeperMessageStatus, &content)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to send message status event")
- }
-}
-
-func (portal *Portal) sendDeliveryReceipt(ctx context.Context, eventID id.EventID) {
- if portal.bridge.Config.Bridge.DeliveryReceipts {
- err := portal.bridge.Bot.SendReceipt(ctx, portal.MXID, eventID, event.ReceiptTypeRead, nil)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to mark message as read by bot (Matrix-side delivery receipt)")
- }
- }
-}
-
-func (portal *Portal) sendMessageMetrics(ctx context.Context, evt *event.Event, err error, part string, ms *metricSender) {
- origEvtID := evt.ID
- if retryMeta := evt.Content.AsMessage().MessageSendRetry; retryMeta != nil {
- origEvtID = retryMeta.OriginalEventID
- }
- if err != nil {
- level := zerolog.ErrorLevel
- if part == "Ignoring" {
- level = zerolog.DebugLevel
- }
- zerolog.Ctx(ctx).WithLevel(level).Err(err).Msg(part + " Matrix event")
- reason, statusCode, isCertain, sendNotice, _ := errorToStatusReason(err)
- checkpointStatus := status.ReasonToCheckpointStatus(reason, statusCode)
- portal.bridge.SendMessageCheckpoint(evt, status.MsgStepRemote, err, checkpointStatus, ms.getRetryNum())
- if sendNotice {
- ms.setNoticeID(portal.sendErrorMessage(ctx, evt, err, isCertain, ms.getNoticeID()))
- }
- portal.sendStatusEvent(ctx, origEvtID, evt.ID, err, nil)
- } else {
- zerolog.Ctx(ctx).Debug().Msg("Successfully handled Matrix event")
- portal.sendDeliveryReceipt(ctx, evt.ID)
- portal.bridge.SendMessageSuccessCheckpoint(evt, status.MsgStepRemote, ms.getRetryNum())
- var deliveredTo *[]id.UserID
- if portal.IsPrivateChat() {
- deliveredTo = &[]id.UserID{}
- }
- portal.sendStatusEvent(ctx, origEvtID, evt.ID, nil, deliveredTo)
- if prevNotice := ms.popNoticeID(); prevNotice != "" {
- _, _ = portal.MainIntent().RedactEvent(ctx, portal.MXID, prevNotice, mautrix.ReqRedact{
- Reason: "error resolved",
- })
- }
- }
- if ms != nil {
- zerolog.Ctx(ctx).Debug().Object("timings", ms.timings).Msg("Matrix event timings")
- }
-}
-
-type messageTimings struct {
- initReceive time.Duration
- decrypt time.Duration
- implicitRR time.Duration
- portalQueue time.Duration
- totalReceive time.Duration
-
- preproc time.Duration
- convert time.Duration
- whatsmeow whatsmeow.MessageDebugTimings
- totalSend time.Duration
-}
-
-func (mt *messageTimings) MarshalZerologObject(e *zerolog.Event) {
- e.Dur("init_receive", mt.initReceive).
- Dur("decrypt", mt.decrypt).
- Dur("implicit_rr", mt.implicitRR).
- Dur("portal_queue", mt.portalQueue).
- Dur("total_receive", mt.totalReceive).
- Dur("preproc", mt.preproc).
- Dur("convert", mt.convert).
- Object("whatsmeow", mt.whatsmeow).
- Dur("total_send", mt.totalSend)
-}
-
-type metricSender struct {
- portal *Portal
- previousNotice id.EventID
- lock sync.Mutex
- completed bool
- retryNum int
- timings *messageTimings
-}
-
-func (ms *metricSender) getRetryNum() int {
- if ms != nil {
- return ms.retryNum
- }
- return 0
-}
-
-func (ms *metricSender) getNoticeID() id.EventID {
- if ms == nil {
- return ""
- }
- return ms.previousNotice
-}
-
-func (ms *metricSender) popNoticeID() id.EventID {
- if ms == nil {
- return ""
- }
- evtID := ms.previousNotice
- ms.previousNotice = ""
- return evtID
-}
-
-func (ms *metricSender) setNoticeID(evtID id.EventID) {
- if ms != nil && ms.previousNotice == "" {
- ms.previousNotice = evtID
- }
-}
-
-func (ms *metricSender) sendMessageMetrics(ctx context.Context, evt *event.Event, err error, part string, completed bool) {
- ms.lock.Lock()
- defer ms.lock.Unlock()
- if !completed && ms.completed {
- return
- }
- ms.portal.sendMessageMetrics(ctx, evt, err, part, ms)
- ms.retryNum++
- ms.completed = completed
-}
diff --git a/metrics.go b/metrics.go
deleted file mode 100644
index a74e9a7..0000000
--- a/metrics.go
+++ /dev/null
@@ -1,320 +0,0 @@
-// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
-// Copyright (C) 2021 Tulir Asokan
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package main
-
-import (
- "context"
- "errors"
- "net/http"
- "runtime/debug"
- "strconv"
- "sync"
- "time"
-
- "github.com/prometheus/client_golang/prometheus"
- "github.com/prometheus/client_golang/prometheus/promauto"
- "github.com/prometheus/client_golang/prometheus/promhttp"
- "github.com/rs/zerolog"
-
- "go.mau.fi/whatsmeow/types"
-
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/id"
-
- "maunium.net/go/mautrix-whatsapp/database"
-)
-
-type MetricsHandler struct {
- db *database.Database
- server *http.Server
- log zerolog.Logger
-
- running bool
- ctx context.Context
- stopRecorder func()
-
- matrixEventHandling *prometheus.HistogramVec
- whatsappMessageAge prometheus.Histogram
- whatsappMessageHandling *prometheus.HistogramVec
- countCollection prometheus.Histogram
- disconnections *prometheus.CounterVec
- incomingRetryReceipts *prometheus.CounterVec
- connectionFailures *prometheus.CounterVec
- puppetCount prometheus.Gauge
- userCount prometheus.Gauge
- messageCount prometheus.Gauge
- portalCount *prometheus.GaugeVec
- encryptedGroupCount prometheus.Gauge
- encryptedPrivateCount prometheus.Gauge
- unencryptedGroupCount prometheus.Gauge
- unencryptedPrivateCount prometheus.Gauge
-
- connected prometheus.Gauge
- connectedState map[string]bool
- connectedStateLock sync.Mutex
- loggedIn prometheus.Gauge
- loggedInState map[string]bool
- loggedInStateLock sync.Mutex
-}
-
-func NewMetricsHandler(address string, log zerolog.Logger, db *database.Database) *MetricsHandler {
- portalCount := promauto.NewGaugeVec(prometheus.GaugeOpts{
- Name: "whatsapp_portals_total",
- Help: "Number of portal rooms on Matrix",
- }, []string{"type", "encrypted"})
- return &MetricsHandler{
- db: db,
- server: &http.Server{Addr: address, Handler: promhttp.Handler()},
- log: log,
- running: false,
-
- matrixEventHandling: promauto.NewHistogramVec(prometheus.HistogramOpts{
- Name: "matrix_event",
- Help: "Time spent processing Matrix events",
- }, []string{"event_type"}),
- whatsappMessageAge: promauto.NewHistogram(prometheus.HistogramOpts{
- Name: "remote_event_age",
- Help: "Age of messages received from WhatsApp",
- Buckets: []float64{1, 2, 3, 5, 7.5, 10, 20, 30, 60},
- }),
- whatsappMessageHandling: promauto.NewHistogramVec(prometheus.HistogramOpts{
- Name: "remote_event",
- Help: "Time spent processing WhatsApp messages",
- }, []string{"message_type"}),
- countCollection: promauto.NewHistogram(prometheus.HistogramOpts{
- Name: "whatsapp_count_collection",
- Help: "Time spent collecting the whatsapp_*_total metrics",
- }),
- disconnections: promauto.NewCounterVec(prometheus.CounterOpts{
- Name: "whatsapp_disconnections",
- Help: "Number of times a Matrix user has been disconnected from WhatsApp",
- }, []string{"user_id"}),
- connectionFailures: promauto.NewCounterVec(prometheus.CounterOpts{
- Name: "whatsapp_connection_failures",
- Help: "Number of times a connection has failed to whatsapp",
- }, []string{"reason"}),
- incomingRetryReceipts: promauto.NewCounterVec(prometheus.CounterOpts{
- Name: "whatsapp_incoming_retry_receipts",
- Help: "Number of times a remote WhatsApp user has requested a retry from the bridge. retry_count = 5 is usually the last attempt (and very likely means a failed message)",
- }, []string{"retry_count", "message_found"}),
- puppetCount: promauto.NewGauge(prometheus.GaugeOpts{
- Name: "whatsapp_puppets_total",
- Help: "Number of WhatsApp users bridged into Matrix",
- }),
- userCount: promauto.NewGauge(prometheus.GaugeOpts{
- Name: "whatsapp_users_total",
- Help: "Number of Matrix users using the bridge",
- }),
- messageCount: promauto.NewGauge(prometheus.GaugeOpts{
- Name: "whatsapp_messages_total",
- Help: "Number of messages bridged",
- }),
- portalCount: portalCount,
- encryptedGroupCount: portalCount.With(prometheus.Labels{"type": "group", "encrypted": "true"}),
- encryptedPrivateCount: portalCount.With(prometheus.Labels{"type": "private", "encrypted": "true"}),
- unencryptedGroupCount: portalCount.With(prometheus.Labels{"type": "group", "encrypted": "false"}),
- unencryptedPrivateCount: portalCount.With(prometheus.Labels{"type": "private", "encrypted": "false"}),
-
- loggedIn: promauto.NewGauge(prometheus.GaugeOpts{
- Name: "bridge_logged_in",
- Help: "Users logged into the bridge",
- }),
- loggedInState: make(map[string]bool),
- connected: promauto.NewGauge(prometheus.GaugeOpts{
- Name: "bridge_connected",
- Help: "Bridge users connected to WhatsApp",
- }),
- connectedState: make(map[string]bool),
- }
-}
-
-func noop() {}
-
-func (mh *MetricsHandler) TrackMatrixEvent(eventType event.Type) func() {
- if !mh.running {
- return noop
- }
- start := time.Now()
- return func() {
- duration := time.Now().Sub(start)
- mh.matrixEventHandling.
- With(prometheus.Labels{"event_type": eventType.Type}).
- Observe(duration.Seconds())
- }
-}
-
-func (mh *MetricsHandler) TrackWhatsAppMessage(timestamp time.Time, messageType string) func() {
- if !mh.running {
- return noop
- }
-
- start := time.Now()
- return func() {
- duration := time.Now().Sub(start)
- mh.whatsappMessageHandling.
- With(prometheus.Labels{"message_type": messageType}).
- Observe(duration.Seconds())
- mh.whatsappMessageAge.Observe(time.Now().Sub(timestamp).Seconds())
- }
-}
-
-func (mh *MetricsHandler) TrackDisconnection(userID id.UserID) {
- if !mh.running {
- return
- }
- mh.disconnections.With(prometheus.Labels{"user_id": string(userID)}).Inc()
-}
-
-func (mh *MetricsHandler) TrackConnectionFailure(reason string) {
- if !mh.running {
- return
- }
- mh.connectionFailures.With(prometheus.Labels{"reason": reason}).Inc()
-}
-
-func (mh *MetricsHandler) TrackRetryReceipt(count int, found bool) {
- if !mh.running {
- return
- }
- mh.incomingRetryReceipts.With(prometheus.Labels{
- "retry_count": strconv.Itoa(count),
- "message_found": strconv.FormatBool(found),
- }).Inc()
-}
-
-func (mh *MetricsHandler) TrackLoginState(jid types.JID, loggedIn bool) {
- if !mh.running {
- return
- }
- mh.loggedInStateLock.Lock()
- defer mh.loggedInStateLock.Unlock()
- currentVal, ok := mh.loggedInState[jid.User]
- if !ok || currentVal != loggedIn {
- mh.loggedInState[jid.User] = loggedIn
- if loggedIn {
- mh.loggedIn.Inc()
- } else {
- mh.loggedIn.Dec()
- }
- }
-}
-
-func (mh *MetricsHandler) TrackConnectionState(jid types.JID, connected bool) {
- if !mh.running {
- return
- }
- mh.connectedStateLock.Lock()
- defer mh.connectedStateLock.Unlock()
- currentVal, ok := mh.connectedState[jid.User]
- if !ok || currentVal != connected {
- mh.connectedState[jid.User] = connected
- if connected {
- mh.connected.Inc()
- } else {
- mh.connected.Dec()
- }
- }
-}
-
-func (mh *MetricsHandler) updateStats() {
- start := time.Now()
- var puppetCount int
- err := mh.db.QueryRow(mh.ctx, "SELECT COUNT(*) FROM puppet").Scan(&puppetCount)
- if err != nil {
- mh.log.Err(err).Msg("Failed to scan number of puppets")
- } else {
- mh.puppetCount.Set(float64(puppetCount))
- }
-
- var userCount int
- err = mh.db.QueryRow(mh.ctx, `SELECT COUNT(*) FROM "user"`).Scan(&userCount)
- if err != nil {
- mh.log.Err(err).Msg("Failed to scan number of users")
- } else {
- mh.userCount.Set(float64(userCount))
- }
-
- var messageCount int
- err = mh.db.QueryRow(mh.ctx, "SELECT COUNT(*) FROM message").Scan(&messageCount)
- if err != nil {
- mh.log.Err(err).Msg("Failed to scan number of messages")
- } else {
- mh.messageCount.Set(float64(messageCount))
- }
-
- var encryptedGroupCount, encryptedPrivateCount, unencryptedGroupCount, unencryptedPrivateCount int
- err = mh.db.QueryRow(mh.ctx, `
- SELECT
- COUNT(CASE WHEN jid LIKE '%@g.us' AND encrypted THEN 1 END) AS encrypted_group_portals,
- COUNT(CASE WHEN jid LIKE '%@s.whatsapp.net' AND encrypted THEN 1 END) AS encrypted_private_portals,
- COUNT(CASE WHEN jid LIKE '%@g.us' AND NOT encrypted THEN 1 END) AS unencrypted_group_portals,
- COUNT(CASE WHEN jid LIKE '%@s.whatsapp.net' AND NOT encrypted THEN 1 END) AS unencrypted_private_portals
- FROM portal WHERE mxid<>''
- `).Scan(&encryptedGroupCount, &encryptedPrivateCount, &unencryptedGroupCount, &unencryptedPrivateCount)
- if err != nil {
- mh.log.Err(err).Msg("Failed to scan number of portals")
- } else {
- mh.encryptedGroupCount.Set(float64(encryptedGroupCount))
- mh.encryptedPrivateCount.Set(float64(encryptedPrivateCount))
- mh.unencryptedGroupCount.Set(float64(unencryptedGroupCount))
- mh.unencryptedPrivateCount.Set(float64(encryptedPrivateCount))
- }
- mh.countCollection.Observe(time.Now().Sub(start).Seconds())
-}
-
-func (mh *MetricsHandler) startUpdatingStats() {
- defer func() {
- err := recover()
- if err != nil {
- mh.log.WithLevel(zerolog.PanicLevel).
- Bytes(zerolog.ErrorStackFieldName, debug.Stack()).
- Interface(zerolog.ErrorFieldName, err).
- Msg("Panic in metric updater")
- }
- }()
- ticker := time.Tick(10 * time.Second)
- for {
- mh.updateStats()
- select {
- case <-mh.ctx.Done():
- return
- case <-ticker:
- }
- }
-}
-
-func (mh *MetricsHandler) Start() {
- mh.running = true
- mh.ctx, mh.stopRecorder = context.WithCancel(context.Background())
- go mh.startUpdatingStats()
- err := mh.server.ListenAndServe()
- mh.running = false
- if err != nil && !errors.Is(err, http.ErrServerClosed) {
- mh.log.Err(err).Msg("Error in metrics listener")
- }
-}
-
-func (mh *MetricsHandler) Stop() {
- if !mh.running {
- return
- }
- mh.stopRecorder()
- err := mh.server.Close()
- if err != nil {
- mh.log.Err(err).Msg("Failed to close metrics listener")
- }
-}
diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go
index 3a505f0..93524a2 100644
--- a/pkg/connector/backfill.go
+++ b/pkg/connector/backfill.go
@@ -87,10 +87,10 @@ func (wa *WhatsAppClient) handleWAHistorySync(ctx context.Context, evt *waHistor
Msg("Failed to parse chat JID in history sync")
continue
} else if jid.Server == types.BroadcastServer {
- log.Debug().Str("chat_jid", jid.String()).Msg("Skipping broadcast list in history sync")
+ log.Debug().Stringer("chat_jid", jid).Msg("Skipping broadcast list in history sync")
continue
} else if jid.Server == types.HiddenUserServer {
- log.Debug().Str("chat_jid", jid.String()).Msg("Skipping hidden user JID chat in history sync")
+ log.Debug().Stringer("chat_jid", jid).Msg("Skipping hidden user JID chat in history sync")
continue
}
totalMessageCount += len(conv.GetMessages())
diff --git a/portal.go b/portal.go
deleted file mode 100644
index e977c03..0000000
--- a/portal.go
+++ /dev/null
@@ -1,5512 +0,0 @@
-// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
-// Copyright (C) 2024 Tulir Asokan
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package main
-
-import (
- "bytes"
- "context"
- "crypto/rand"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "errors"
- "fmt"
- "image"
- "image/color"
- _ "image/gif"
- "image/jpeg"
- "image/png"
- "io"
- "maps"
- "math"
- "mime"
- "net/http"
- "reflect"
- "runtime/debug"
- "strconv"
- "strings"
- "sync"
- "time"
-
- "github.com/rs/zerolog"
- "github.com/tidwall/gjson"
- "go.mau.fi/util/exzerolog"
- cwebp "go.mau.fi/webp"
- "go.mau.fi/whatsmeow"
- waProto "go.mau.fi/whatsmeow/binary/proto"
- "go.mau.fi/whatsmeow/proto/waMmsRetry"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
- "golang.org/x/exp/slices"
- "golang.org/x/image/draw"
- "golang.org/x/image/webp"
- "google.golang.org/protobuf/proto"
-
- "go.mau.fi/util/exerrors"
- "go.mau.fi/util/exmime"
- "go.mau.fi/util/ffmpeg"
- "go.mau.fi/util/jsontime"
- "go.mau.fi/util/random"
- "go.mau.fi/util/variationselector"
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/appservice"
- "maunium.net/go/mautrix/bridge"
- "maunium.net/go/mautrix/bridge/bridgeconfig"
- "maunium.net/go/mautrix/bridge/status"
- "maunium.net/go/mautrix/crypto/attachment"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/format"
- "maunium.net/go/mautrix/id"
-
- "maunium.net/go/mautrix-whatsapp/database"
-)
-
-const StatusBroadcastTopic = "WhatsApp status updates from your contacts"
-const StatusBroadcastName = "WhatsApp Status Broadcast"
-const BroadcastTopic = "WhatsApp broadcast list"
-const UnnamedBroadcastName = "Unnamed broadcast list"
-const PrivateChatTopic = "WhatsApp private chat"
-
-var ErrStatusBroadcastDisabled = errors.New("status bridging is disabled")
-
-func (br *WABridge) GetPortalByMXID(mxid id.RoomID) *Portal {
- ctx := context.TODO()
- br.portalsLock.Lock()
- defer br.portalsLock.Unlock()
- portal, ok := br.portalsByMXID[mxid]
- if !ok {
- dbPortal, err := br.DB.Portal.GetByMXID(ctx, mxid)
- if err != nil {
- br.ZLog.Err(err).Stringer("mxid", mxid).Msg("Failed to get portal by MXID")
- return nil
- }
- return br.loadDBPortal(ctx, dbPortal, nil)
- }
- return portal
-}
-
-func (br *WABridge) GetIPortal(mxid id.RoomID) bridge.Portal {
- p := br.GetPortalByMXID(mxid)
- if p == nil {
- return nil
- }
- return p
-}
-
-func (portal *Portal) IsEncrypted() bool {
- return portal.Encrypted
-}
-
-func (portal *Portal) MarkEncrypted() {
- portal.Encrypted = true
- err := portal.Update(context.TODO())
- if err != nil {
- portal.zlog.Err(err).Msg("Failed to mark portal as encrypted")
- }
-}
-
-func (portal *Portal) ReceiveMatrixEvent(user bridge.User, evt *event.Event) {
- if user.GetPermissionLevel() >= bridgeconfig.PermissionLevelUser || portal.HasRelaybot() {
- portal.events <- &PortalEvent{
- MatrixMessage: &PortalMatrixMessage{
- user: user.(*User),
- evt: evt,
- receivedAt: time.Now(),
- },
- }
- }
-}
-
-func (br *WABridge) GetPortalByJID(key database.PortalKey) *Portal {
- ctx := context.TODO()
- br.portalsLock.Lock()
- defer br.portalsLock.Unlock()
- portal, ok := br.portalsByJID[key]
- if !ok {
- dbPortal, err := br.DB.Portal.GetByJID(ctx, key)
- if err != nil {
- br.ZLog.Err(err).Str("key", key.String()).Msg("Failed to get portal by JID")
- return nil
- }
- return br.loadDBPortal(ctx, dbPortal, &key)
- }
- return portal
-}
-
-func (br *WABridge) GetExistingPortalByJID(key database.PortalKey) *Portal {
- ctx := context.TODO()
- br.portalsLock.Lock()
- defer br.portalsLock.Unlock()
- portal, ok := br.portalsByJID[key]
- if !ok {
- dbPortal, err := br.DB.Portal.GetByJID(ctx, key)
- if err != nil {
- br.ZLog.Err(err).Str("key", key.String()).Msg("Failed to get portal by JID")
- return nil
- }
- return br.loadDBPortal(ctx, dbPortal, nil)
- }
- return portal
-}
-
-func (br *WABridge) GetAllPortals() []*Portal {
- return br.dbPortalsToPortals(br.DB.Portal.GetAll(context.TODO()))
-}
-
-func (br *WABridge) GetAllIPortals() (iportals []bridge.Portal) {
- portals := br.GetAllPortals()
- iportals = make([]bridge.Portal, len(portals))
- for i, portal := range portals {
- iportals[i] = portal
- }
- return iportals
-}
-
-func (br *WABridge) GetAllPortalsByJID(jid types.JID) []*Portal {
- return br.dbPortalsToPortals(br.DB.Portal.GetAllByJID(context.TODO(), jid))
-}
-
-func (br *WABridge) GetAllByParentGroup(jid types.JID) []*Portal {
- return br.dbPortalsToPortals(br.DB.Portal.GetAllByParentGroup(context.TODO(), jid))
-}
-
-func (br *WABridge) dbPortalsToPortals(dbPortals []*database.Portal, err error) []*Portal {
- if err != nil {
- br.ZLog.Err(err).Msg("Failed to get portals")
- return nil
- }
- br.portalsLock.Lock()
- defer br.portalsLock.Unlock()
- output := make([]*Portal, len(dbPortals))
- for index, dbPortal := range dbPortals {
- if dbPortal == nil {
- continue
- }
- portal, ok := br.portalsByJID[dbPortal.Key]
- if !ok {
- portal = br.loadDBPortal(context.TODO(), dbPortal, nil)
- }
- output[index] = portal
- }
- return output
-}
-
-func (br *WABridge) loadDBPortal(ctx context.Context, dbPortal *database.Portal, key *database.PortalKey) *Portal {
- if dbPortal == nil {
- if key == nil {
- return nil
- }
- dbPortal = br.DB.Portal.New()
- dbPortal.Key = *key
- err := dbPortal.Insert(ctx)
- if err != nil {
- br.ZLog.Err(err).Str("key", key.String()).Msg("Failed to insert new portal")
- return nil
- }
- }
- portal := br.NewPortal(dbPortal)
- br.portalsByJID[portal.Key] = portal
- if len(portal.MXID) > 0 {
- br.portalsByMXID[portal.MXID] = portal
- }
- return portal
-}
-
-func (portal *Portal) GetUsers() []*User {
- // TODO what's this for?
- return nil
-}
-
-func (br *WABridge) NewManualPortal(key database.PortalKey) *Portal {
- dbPortal := br.DB.Portal.New()
- dbPortal.Key = key
- return br.NewPortal(dbPortal)
-}
-
-func (br *WABridge) NewPortal(dbPortal *database.Portal) *Portal {
- portal := &Portal{
- Portal: dbPortal,
- bridge: br,
- events: make(chan *PortalEvent, br.Config.Bridge.PortalMessageBuffer),
- mediaErrorCache: make(map[types.MessageID]*FailedMediaMeta),
- }
- portal.updateLogger()
- go portal.handleMessageLoop()
- return portal
-}
-
-func (portal *Portal) updateLogger() {
- logWith := portal.bridge.ZLog.With().Stringer("portal_key", portal.Key)
- if portal.MXID != "" {
- logWith = logWith.Stringer("room_id", portal.MXID)
- }
- portal.zlog = logWith.Logger()
-}
-
-const recentlyHandledLength = 100
-
-type fakeMessage struct {
- Sender types.JID
- Text string
- ID string
- Time time.Time
- Important bool
-}
-
-type PortalEvent struct {
- Message *PortalMessage
- MatrixMessage *PortalMatrixMessage
-}
-
-type PortalMessage struct {
- evt *events.Message
- undecryptable *events.UndecryptableMessage
- receipt *events.Receipt
- fake *fakeMessage
- source *User
-}
-
-type PortalMatrixMessage struct {
- evt *event.Event
- user *User
- receivedAt time.Time
-}
-
-type recentlyHandledWrapper struct {
- id types.MessageID
- err database.MessageErrorType
-}
-
-type Portal struct {
- *database.Portal
-
- bridge *WABridge
- zlog zerolog.Logger
-
- roomCreateLock sync.Mutex
- encryptLock sync.Mutex
- backfillLock sync.Mutex
- avatarLock sync.Mutex
-
- latestEventBackfillLock sync.Mutex
- parentGroupUpdateLock sync.Mutex
-
- recentlyHandled [recentlyHandledLength]recentlyHandledWrapper
- recentlyHandledLock sync.Mutex
- recentlyHandledIndex uint8
-
- currentlyTyping []id.UserID
- currentlyTypingLock sync.Mutex
-
- events chan *PortalEvent
-
- mediaErrorCache map[types.MessageID]*FailedMediaMeta
-
- galleryCache []*event.MessageEventContent
- galleryCacheRootEvent id.EventID
- galleryCacheStart time.Time
- galleryCacheReplyTo *ReplyInfo
- galleryCacheSender types.JID
-
- currentlySleepingToDelete sync.Map
-
- relayUser *User
- parentPortal *Portal
-}
-
-const GalleryMaxTime = 10 * time.Minute
-
-func (portal *Portal) stopGallery() {
- if portal.galleryCache != nil {
- portal.galleryCache = nil
- portal.galleryCacheSender = types.EmptyJID
- portal.galleryCacheReplyTo = nil
- portal.galleryCacheStart = time.Time{}
- portal.galleryCacheRootEvent = ""
- }
-}
-
-func (portal *Portal) startGallery(evt *events.Message, msg *ConvertedMessage) {
- portal.galleryCache = []*event.MessageEventContent{msg.Content}
- portal.galleryCacheSender = evt.Info.Sender.ToNonAD()
- portal.galleryCacheReplyTo = msg.ReplyTo
- portal.galleryCacheStart = time.Now()
-}
-
-func (portal *Portal) extendGallery(msg *ConvertedMessage) int {
- portal.galleryCache = append(portal.galleryCache, msg.Content)
- msg.Content = &event.MessageEventContent{
- MsgType: event.MsgBeeperGallery,
- Body: "Sent a gallery",
- BeeperGalleryImages: portal.galleryCache,
- }
- msg.Content.SetEdit(portal.galleryCacheRootEvent)
- // Don't set the gallery images in the edit fallback
- msg.Content.BeeperGalleryImages = nil
- return len(portal.galleryCache) - 1
-}
-
-var (
- _ bridge.Portal = (*Portal)(nil)
- _ bridge.ReadReceiptHandlingPortal = (*Portal)(nil)
- _ bridge.MembershipHandlingPortal = (*Portal)(nil)
- _ bridge.MetaHandlingPortal = (*Portal)(nil)
- _ bridge.TypingPortal = (*Portal)(nil)
-)
-
-func (portal *Portal) handleWhatsAppMessageLoopItem(msg *PortalMessage) {
- log := portal.zlog.With().
- Str("action", "handle whatsapp event").
- Stringer("source_user_jid", msg.source.JID).
- Stringer("source_user_mxid", msg.source.MXID).
- Logger()
- ctx := log.WithContext(context.TODO())
- if len(portal.MXID) == 0 {
- if msg.fake == nil && msg.undecryptable == nil && (msg.evt == nil || !containsSupportedMessage(msg.evt.Message)) {
- log.Debug().Msg("Not creating portal room for incoming message: message is not a chat message")
- return
- }
- log.Debug().Msg("Creating Matrix room from incoming message")
- err := portal.CreateMatrixRoom(ctx, msg.source, nil, nil, false, true)
- if err != nil {
- log.Err(err).Msg("Failed to create portal room")
- return
- }
- }
- portal.latestEventBackfillLock.Lock()
- defer portal.latestEventBackfillLock.Unlock()
- switch {
- case msg.evt != nil:
- log.UpdateContext(func(c zerolog.Context) zerolog.Context {
- return c.
- Str("message_id", msg.evt.Info.ID).
- Stringer("message_sender", msg.evt.Info.Sender)
- })
- portal.handleMessage(ctx, msg.source, msg.evt, false)
- case msg.receipt != nil:
- log.UpdateContext(func(c zerolog.Context) zerolog.Context {
- return c.Str("receipt_type", msg.receipt.Type.GoString())
- })
- portal.handleReceipt(ctx, msg.receipt, msg.source)
- case msg.undecryptable != nil:
- log.UpdateContext(func(c zerolog.Context) zerolog.Context {
- return c.
- Str("message_id", msg.undecryptable.Info.ID).
- Stringer("message_sender", msg.undecryptable.Info.Sender).
- Bool("undecryptable", true)
- })
- portal.stopGallery()
- portal.handleUndecryptableMessage(ctx, msg.source, msg.undecryptable)
- case msg.fake != nil:
- log.UpdateContext(func(c zerolog.Context) zerolog.Context {
- return c.
- Str("fake_message_id", msg.fake.ID).
- Stringer("message_sender", msg.fake.Sender)
- })
- portal.stopGallery()
- msg.fake.ID = "FAKE::" + msg.fake.ID
- portal.handleFakeMessage(ctx, *msg.fake)
- default:
- log.Warn().Any("event_data", msg).Msg("Unexpected PortalMessage with no message")
- }
-}
-
-func (portal *Portal) handleMatrixMessageLoopItem(msg *PortalMatrixMessage) {
- log := portal.zlog.With().
- Str("action", "handle matrix event").
- Stringer("event_id", msg.evt.ID).
- Str("event_type", msg.evt.Type.Type).
- Stringer("sender", msg.evt.Sender).
- Logger()
- ctx := log.WithContext(context.TODO())
- portal.latestEventBackfillLock.Lock()
- defer portal.latestEventBackfillLock.Unlock()
- evtTS := time.UnixMilli(msg.evt.Timestamp)
- timings := messageTimings{
- initReceive: msg.evt.Mautrix.ReceivedAt.Sub(evtTS),
- decrypt: msg.evt.Mautrix.DecryptionDuration,
- portalQueue: time.Since(msg.receivedAt),
- totalReceive: time.Since(evtTS),
- }
- implicitRRStart := time.Now()
- portal.handleMatrixReadReceipt(ctx, msg.user, "", evtTS, false)
- timings.implicitRR = time.Since(implicitRRStart)
- switch msg.evt.Type {
- case event.EventMessage, event.EventSticker, TypeMSC3381V2PollResponse, TypeMSC3381PollResponse, TypeMSC3381PollStart:
- portal.HandleMatrixMessage(ctx, msg.user, msg.evt, timings)
- case event.EventRedaction:
- log.UpdateContext(func(c zerolog.Context) zerolog.Context {
- return c.Stringer("redaction_target_mxid", msg.evt.Redacts)
- })
- portal.HandleMatrixRedaction(ctx, msg.user, msg.evt)
- case event.EventReaction:
- portal.HandleMatrixReaction(ctx, msg.user, msg.evt)
- default:
- log.Warn().Msg("Unsupported event type in portal message channel")
- }
-}
-
-func (portal *Portal) handleDeliveryReceipt(ctx context.Context, receipt *events.Receipt, source *User) {
- if !portal.IsPrivateChat() {
- return
- }
- log := zerolog.Ctx(ctx)
- for _, msgID := range receipt.MessageIDs {
- msg, err := portal.bridge.DB.Message.GetByJID(ctx, portal.Key, msgID)
- if err != nil {
- log.Err(err).Str("message_id", msgID).Msg("Failed to get receipt target message")
- continue
- } else if msg == nil || msg.IsFakeMXID() {
- continue
- }
- if msg.Sender == source.JID {
- portal.bridge.SendRawMessageCheckpoint(&status.MessageCheckpoint{
- EventID: msg.MXID,
- RoomID: portal.MXID,
- Step: status.MsgStepRemote,
- Timestamp: jsontime.UM(receipt.Timestamp),
- Status: status.MsgStatusDelivered,
- ReportedBy: status.MsgReportedByBridge,
- })
- portal.sendStatusEvent(ctx, msg.MXID, "", nil, &[]id.UserID{portal.MainIntent().UserID})
- }
- }
-}
-
-func (portal *Portal) handleReceipt(ctx context.Context, receipt *events.Receipt, source *User) {
- if receipt.Sender.Server != types.DefaultUserServer {
- // TODO handle lids
- return
- }
- if receipt.Type == types.ReceiptTypeDelivered {
- portal.handleDeliveryReceipt(ctx, receipt, source)
- return
- }
- // The order of the message ID array depends on the sender's platform, so we just have to find
- // the last message based on timestamp. Also, timestamps only have second precision, so if
- // there are many messages at the same second just mark them all as read, because we don't
- // know which one is last
- markAsRead := make([]*database.Message, 0, 1)
- var bestTimestamp time.Time
- log := zerolog.Ctx(ctx)
- for _, msgID := range receipt.MessageIDs {
- msg, err := portal.bridge.DB.Message.GetByJID(ctx, portal.Key, msgID)
- if err != nil {
- log.Err(err).Str("message_id", msgID).Msg("Failed to get receipt target message")
- } else if msg == nil || msg.IsFakeMXID() {
- continue
- }
- if msg.Timestamp.After(bestTimestamp) {
- bestTimestamp = msg.Timestamp
- markAsRead = append(markAsRead[:0], msg)
- } else if msg != nil && msg.Timestamp.Equal(bestTimestamp) {
- markAsRead = append(markAsRead, msg)
- }
- }
- if receipt.Sender.User == source.JID.User {
- if len(markAsRead) > 0 {
- source.SetLastReadTS(ctx, portal.Key, markAsRead[0].Timestamp)
- } else {
- source.SetLastReadTS(ctx, portal.Key, receipt.Timestamp)
- }
- }
- intent := portal.bridge.GetPuppetByJID(receipt.Sender).IntentFor(portal)
- for _, msg := range markAsRead {
- err := intent.SetReadMarkers(ctx, portal.MXID, source.makeReadMarkerContent(msg.MXID, intent.IsCustomPuppet))
- if err != nil {
- log.Err(err).
- Stringer("message_mxid", msg.MXID).
- Stringer("read_by_user_mxid", intent.UserID).
- Msg("Failed to mark message as read")
- } else {
- log.Debug().
- Stringer("message_mxid", msg.MXID).
- Stringer("read_by_user_mxid", intent.UserID).
- Msg("Marked message as read")
- }
- }
-}
-
-func (portal *Portal) handleMessageLoop() {
- for {
- portal.handleOneMessageLoopItem()
- }
-}
-
-func (portal *Portal) handleOneMessageLoopItem() {
- defer func() {
- if err := recover(); err != nil {
- logEvt := portal.zlog.WithLevel(zerolog.FatalLevel).
- Str(zerolog.ErrorStackFieldName, string(debug.Stack()))
- actualErr, ok := err.(error)
- if ok {
- logEvt = logEvt.Err(actualErr)
- } else {
- logEvt = logEvt.Any(zerolog.ErrorFieldName, err)
- }
- logEvt.Msg("Portal message handler panicked")
- }
- }()
- select {
- case msg := <-portal.events:
- if msg.Message != nil {
- portal.handleWhatsAppMessageLoopItem(msg.Message)
- } else if msg.MatrixMessage != nil {
- portal.handleMatrixMessageLoopItem(msg.MatrixMessage)
- } else {
- portal.zlog.Warn().Msg("Unexpected PortalEvent with no data")
- }
- }
-}
-
-func containsSupportedMessage(waMsg *waProto.Message) bool {
- if waMsg == nil {
- return false
- }
- return waMsg.Conversation != nil || waMsg.ExtendedTextMessage != nil || waMsg.ImageMessage != nil ||
- waMsg.StickerMessage != nil || waMsg.AudioMessage != nil || waMsg.VideoMessage != nil || waMsg.PtvMessage != nil ||
- waMsg.DocumentMessage != nil || waMsg.ContactMessage != nil || waMsg.LocationMessage != nil ||
- waMsg.LiveLocationMessage != nil || waMsg.GroupInviteMessage != nil || waMsg.ContactsArrayMessage != nil ||
- waMsg.HighlyStructuredMessage != nil || waMsg.TemplateMessage != nil || waMsg.TemplateButtonReplyMessage != nil ||
- waMsg.ListMessage != nil || waMsg.ListResponseMessage != nil || waMsg.PollCreationMessage != nil || waMsg.PollCreationMessageV2 != nil
-}
-
-func getMessageType(waMsg *waProto.Message) string {
- switch {
- case waMsg == nil:
- return "ignore"
- case waMsg.Conversation != nil, waMsg.ExtendedTextMessage != nil:
- return "text"
- case waMsg.ImageMessage != nil:
- return fmt.Sprintf("image %s", waMsg.GetImageMessage().GetMimetype())
- case waMsg.StickerMessage != nil:
- return fmt.Sprintf("sticker %s", waMsg.GetStickerMessage().GetMimetype())
- case waMsg.VideoMessage != nil:
- return fmt.Sprintf("video %s", waMsg.GetVideoMessage().GetMimetype())
- case waMsg.PtvMessage != nil:
- return fmt.Sprintf("round video %s", waMsg.GetPtvMessage().GetMimetype())
- case waMsg.AudioMessage != nil:
- return fmt.Sprintf("audio %s", waMsg.GetAudioMessage().GetMimetype())
- case waMsg.DocumentMessage != nil:
- return fmt.Sprintf("document %s", waMsg.GetDocumentMessage().GetMimetype())
- case waMsg.ContactMessage != nil:
- return "contact"
- case waMsg.ContactsArrayMessage != nil:
- return "contact array"
- case waMsg.LocationMessage != nil:
- return "location"
- case waMsg.LiveLocationMessage != nil:
- return "live location start"
- case waMsg.GroupInviteMessage != nil:
- return "group invite"
- case waMsg.ReactionMessage != nil:
- return "reaction"
- case waMsg.EncReactionMessage != nil:
- return "encrypted reaction"
- case waMsg.PollCreationMessage != nil || waMsg.PollCreationMessageV2 != nil || waMsg.PollCreationMessageV3 != nil:
- return "poll create"
- case waMsg.PollUpdateMessage != nil:
- return "poll update"
- case waMsg.ProtocolMessage != nil:
- switch waMsg.GetProtocolMessage().GetType() {
- case waProto.ProtocolMessage_REVOKE:
- if waMsg.GetProtocolMessage().GetKey() == nil {
- return "ignore"
- }
- return "revoke"
- case waProto.ProtocolMessage_MESSAGE_EDIT:
- return "edit"
- case waProto.ProtocolMessage_EPHEMERAL_SETTING:
- return "disappearing timer change"
- case waProto.ProtocolMessage_APP_STATE_SYNC_KEY_SHARE, waProto.ProtocolMessage_HISTORY_SYNC_NOTIFICATION, waProto.ProtocolMessage_INITIAL_SECURITY_NOTIFICATION_SETTING_SYNC:
- return "ignore"
- default:
- return fmt.Sprintf("unknown_protocol_%d", waMsg.GetProtocolMessage().GetType())
- }
- case waMsg.ButtonsMessage != nil:
- return "buttons"
- case waMsg.ButtonsResponseMessage != nil:
- return "buttons response"
- case waMsg.TemplateMessage != nil:
- return "template"
- case waMsg.HighlyStructuredMessage != nil:
- return "highly structured template"
- case waMsg.TemplateButtonReplyMessage != nil:
- return "template button reply"
- case waMsg.InteractiveMessage != nil:
- return "interactive"
- case waMsg.ListMessage != nil:
- return "list"
- case waMsg.ProductMessage != nil:
- return "product"
- case waMsg.ListResponseMessage != nil:
- return "list response"
- case waMsg.OrderMessage != nil:
- return "order"
- case waMsg.InvoiceMessage != nil:
- return "invoice"
- case waMsg.SendPaymentMessage != nil, waMsg.RequestPaymentMessage != nil,
- waMsg.DeclinePaymentRequestMessage != nil, waMsg.CancelPaymentRequestMessage != nil,
- waMsg.PaymentInviteMessage != nil:
- return "payment"
- case waMsg.Call != nil:
- return "call"
- case waMsg.Chat != nil:
- return "chat"
- case waMsg.SenderKeyDistributionMessage != nil, waMsg.StickerSyncRmrMessage != nil:
- return "ignore"
- default:
- return "unknown"
- }
-}
-
-func pluralUnit(val int, name string) string {
- if val == 1 {
- return fmt.Sprintf("%d %s", val, name)
- } else if val == 0 {
- return ""
- }
- return fmt.Sprintf("%d %ss", val, name)
-}
-
-func naturalJoin(parts []string) string {
- if len(parts) == 0 {
- return ""
- } else if len(parts) == 1 {
- return parts[0]
- } else if len(parts) == 2 {
- return fmt.Sprintf("%s and %s", parts[0], parts[1])
- } else {
- return fmt.Sprintf("%s and %s", strings.Join(parts[:len(parts)-1], ", "), parts[len(parts)-1])
- }
-}
-
-func formatDuration(d time.Duration) string {
- const Day = time.Hour * 24
-
- var days, hours, minutes, seconds int
- days, d = int(d/Day), d%Day
- hours, d = int(d/time.Hour), d%time.Hour
- minutes, d = int(d/time.Minute), d%time.Minute
- seconds = int(d / time.Second)
-
- parts := make([]string, 0, 4)
- if days > 0 {
- parts = append(parts, pluralUnit(days, "day"))
- }
- if hours > 0 {
- parts = append(parts, pluralUnit(hours, "hour"))
- }
- if minutes > 0 {
- parts = append(parts, pluralUnit(seconds, "minute"))
- }
- if seconds > 0 {
- parts = append(parts, pluralUnit(seconds, "second"))
- }
- return naturalJoin(parts)
-}
-
-func (portal *Portal) convertMessage(ctx context.Context, intent *appservice.IntentAPI, source *User, info *types.MessageInfo, waMsg *waProto.Message, isBackfill bool) *ConvertedMessage {
- switch {
- case waMsg.Conversation != nil || waMsg.ExtendedTextMessage != nil:
- return portal.convertTextMessage(ctx, intent, source, waMsg)
- case waMsg.TemplateMessage != nil:
- return portal.convertTemplateMessage(ctx, intent, source, info, waMsg.GetTemplateMessage())
- case waMsg.HighlyStructuredMessage != nil:
- return portal.convertTemplateMessage(ctx, intent, source, info, waMsg.GetHighlyStructuredMessage().GetHydratedHsm())
- case waMsg.TemplateButtonReplyMessage != nil:
- return portal.convertTemplateButtonReplyMessage(ctx, intent, waMsg.GetTemplateButtonReplyMessage())
- case waMsg.ListMessage != nil:
- return portal.convertListMessage(ctx, intent, source, waMsg.GetListMessage())
- case waMsg.ListResponseMessage != nil:
- return portal.convertListResponseMessage(ctx, intent, waMsg.GetListResponseMessage())
- case waMsg.PollCreationMessage != nil:
- return portal.convertPollCreationMessage(ctx, intent, waMsg.GetPollCreationMessage())
- case waMsg.PollCreationMessageV2 != nil:
- return portal.convertPollCreationMessage(ctx, intent, waMsg.GetPollCreationMessageV2())
- case waMsg.PollCreationMessageV3 != nil:
- return portal.convertPollCreationMessage(ctx, intent, waMsg.GetPollCreationMessageV3())
- case waMsg.PollUpdateMessage != nil:
- return portal.convertPollUpdateMessage(ctx, intent, source, info, waMsg.GetPollUpdateMessage())
- case waMsg.ImageMessage != nil:
- return portal.convertMediaMessage(ctx, intent, source, info, waMsg.GetImageMessage(), "photo", isBackfill)
- case waMsg.StickerMessage != nil:
- return portal.convertMediaMessage(ctx, intent, source, info, waMsg.GetStickerMessage(), "sticker", isBackfill)
- case waMsg.VideoMessage != nil:
- return portal.convertMediaMessage(ctx, intent, source, info, waMsg.GetVideoMessage(), "video attachment", isBackfill)
- case waMsg.PtvMessage != nil:
- return portal.convertMediaMessage(ctx, intent, source, info, waMsg.GetPtvMessage(), "video message", isBackfill)
- case waMsg.AudioMessage != nil:
- typeName := "audio attachment"
- if waMsg.GetAudioMessage().GetPTT() {
- typeName = "voice message"
- }
- return portal.convertMediaMessage(ctx, intent, source, info, waMsg.GetAudioMessage(), typeName, isBackfill)
- case waMsg.DocumentMessage != nil:
- return portal.convertMediaMessage(ctx, intent, source, info, waMsg.GetDocumentMessage(), "file attachment", isBackfill)
- case waMsg.ContactMessage != nil:
- return portal.convertContactMessage(ctx, intent, waMsg.GetContactMessage())
- case waMsg.ContactsArrayMessage != nil:
- return portal.convertContactsArrayMessage(ctx, intent, waMsg.GetContactsArrayMessage())
- case waMsg.LocationMessage != nil:
- return portal.convertLocationMessage(ctx, intent, waMsg.GetLocationMessage())
- case waMsg.LiveLocationMessage != nil:
- return portal.convertLiveLocationMessage(ctx, intent, waMsg.GetLiveLocationMessage())
- case waMsg.GroupInviteMessage != nil:
- return portal.convertGroupInviteMessage(ctx, intent, info, waMsg.GetGroupInviteMessage())
- case waMsg.ProtocolMessage != nil && waMsg.ProtocolMessage.GetType() == waProto.ProtocolMessage_EPHEMERAL_SETTING:
- portal.ExpirationTime = waMsg.ProtocolMessage.GetEphemeralExpiration()
- err := portal.Update(ctx)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal after updating expiration timer")
- }
- return &ConvertedMessage{
- Intent: intent,
- Type: event.EventMessage,
- Content: &event.MessageEventContent{
- Body: portal.formatDisappearingMessageNotice(),
- MsgType: event.MsgNotice,
- },
- }
- default:
- return nil
- }
-}
-
-func (portal *Portal) implicitlyEnableDisappearingMessages(ctx context.Context, timer time.Duration) {
- portal.ExpirationTime = uint32(timer.Seconds())
- err := portal.Update(ctx)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal after implicitly enabling disappearing timer")
- }
- intent := portal.MainIntent()
- if portal.Encrypted {
- intent = portal.bridge.Bot
- }
- duration := formatDuration(time.Duration(portal.ExpirationTime) * time.Second)
- _, err = portal.sendMessage(ctx, intent, event.EventMessage, &event.MessageEventContent{
- MsgType: event.MsgNotice,
- Body: fmt.Sprintf("Automatically enabled disappearing message timer (%s) because incoming message is disappearing", duration),
- }, nil, 0)
- if err != nil {
- zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to send notice about implicit disappearing timer")
- }
-}
-
-func (portal *Portal) UpdateGroupDisappearingMessages(ctx context.Context, sender *types.JID, timestamp time.Time, timer uint32) {
- if portal.ExpirationTime == timer {
- return
- }
- portal.ExpirationTime = timer
- err := portal.Update(ctx)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal after updating expiration timer")
- }
- intent := portal.MainIntent()
- if sender != nil && sender.Server == types.DefaultUserServer {
- intent = portal.bridge.GetPuppetByJID(sender.ToNonAD()).IntentFor(portal)
- } else {
- sender = &types.EmptyJID
- }
- _, err = portal.sendMessage(ctx, intent, event.EventMessage, &event.MessageEventContent{
- Body: portal.formatDisappearingMessageNotice(),
- MsgType: event.MsgNotice,
- }, nil, timestamp.UnixMilli())
- if err != nil {
- zerolog.Ctx(ctx).Warn().Err(err).
- Uint32("new_timer", timer).
- Stringer("sender_jid", sender).
- Msg("Failed to notify portal about disappearing message timer change")
- }
-}
-
-func (portal *Portal) formatDisappearingMessageNotice() string {
- if portal.ExpirationTime == 0 {
- return "Turned off disappearing messages"
- }
- return fmt.Sprintf("Set the disappearing message timer to %s", formatDuration(time.Duration(portal.ExpirationTime)*time.Second))
-}
-
-const UndecryptableMessageNotice = "Decrypting message from WhatsApp failed, waiting for sender to re-send... " +
- "([learn more](https://faq.whatsapp.com/general/security-and-privacy/seeing-waiting-for-this-message-this-may-take-a-while))"
-
-var undecryptableMessageContent event.MessageEventContent
-
-func init() {
- undecryptableMessageContent = format.RenderMarkdown(UndecryptableMessageNotice, true, false)
- undecryptableMessageContent.MsgType = event.MsgNotice
-}
-
-func (portal *Portal) handleUndecryptableMessage(ctx context.Context, source *User, evt *events.UndecryptableMessage) {
- log := zerolog.Ctx(ctx)
- if len(portal.MXID) == 0 {
- log.Warn().Msg("handleUndecryptableMessage called even though portal.MXID is empty")
- return
- } else if portal.isRecentlyHandled(evt.Info.ID, database.MsgErrDecryptionFailed) {
- log.Debug().Msg("Not handling recently handled message")
- return
- } else if existingMsg, err := portal.bridge.DB.Message.GetByJID(ctx, portal.Key, evt.Info.ID); err != nil {
- log.Err(err).Msg("Failed to get message from database to check if undecryptable message is duplicate")
- return
- } else if existingMsg != nil {
- log.Debug().Msg("Not handling duplicate message")
- return
- }
- metricType := "error"
- if evt.IsUnavailable {
- metricType = "unavailable"
- }
- Analytics.Track(source.MXID, "WhatsApp undecryptable message", map[string]interface{}{
- "messageID": evt.Info.ID,
- "undecryptableType": metricType,
- })
- intent := portal.getMessageIntent(ctx, source, &evt.Info)
- if intent == nil {
- return
- }
- content := undecryptableMessageContent
- resp, err := portal.sendMessage(ctx, intent, event.EventMessage, &content, nil, evt.Info.Timestamp.UnixMilli())
- if err != nil {
- log.Err(err).Msg("Failed to send WhatsApp decryption error message to Matrix")
- return
- }
- portal.finishHandling(ctx, nil, &evt.Info, resp.EventID, intent.UserID, database.MsgUnknown, 0, database.MsgErrDecryptionFailed)
-}
-
-func (portal *Portal) handleFakeMessage(ctx context.Context, msg fakeMessage) {
- log := zerolog.Ctx(ctx)
- if portal.isRecentlyHandled(msg.ID, database.MsgNoError) {
- log.Debug().Msg("Not handling recently handled message")
- return
- } else if existingMsg, err := portal.bridge.DB.Message.GetByJID(ctx, portal.Key, msg.ID); err != nil {
- log.Err(err).Msg("Failed to get message from database to check if fake message is duplicate")
- return
- } else if existingMsg != nil {
- log.Debug().Msg("Not handling duplicate message")
- return
- }
- if msg.Sender.Server != types.DefaultUserServer {
- log.Debug().Msg("Not handling message from @lid user")
- // TODO handle lids
- return
- }
- intent := portal.bridge.GetPuppetByJID(msg.Sender).IntentFor(portal)
- if !intent.IsCustomPuppet && portal.IsPrivateChat() && msg.Sender.User == portal.Key.Receiver.User && portal.Key.Receiver != portal.Key.JID {
- log.Debug().Msg("Not handling fake message for user who doesn't have double puppeting enabled")
- return
- }
- msgType := event.MsgNotice
- if msg.Important {
- msgType = event.MsgText
- }
- resp, err := portal.sendMessage(ctx, intent, event.EventMessage, &event.MessageEventContent{
- MsgType: msgType,
- Body: msg.Text,
- }, nil, msg.Time.UnixMilli())
- if err != nil {
- log.Err(err).Msg("Failed to send fake message to Matrix")
- } else {
- portal.finishHandling(ctx, nil, &types.MessageInfo{
- ID: msg.ID,
- Timestamp: msg.Time,
- MessageSource: types.MessageSource{
- Sender: msg.Sender,
- },
- }, resp.EventID, intent.UserID, database.MsgFake, 0, database.MsgNoError)
- }
-}
-
-func (portal *Portal) handleMessage(ctx context.Context, source *User, evt *events.Message, historical bool) {
- log := zerolog.Ctx(ctx)
- if len(portal.MXID) == 0 {
- log.Warn().Msg("handleMessage called even though portal.MXID is empty")
- return
- }
- msgID := evt.Info.ID
- msgType := getMessageType(evt.Message)
- if msgType == "ignore" {
- return
- } else if portal.isRecentlyHandled(msgID, database.MsgNoError) {
- log.Debug().Msg("Not handling recently handled message")
- return
- }
- log.UpdateContext(func(c zerolog.Context) zerolog.Context {
- return c.Str("wa_message_type", msgType)
- })
- existingMsg, err := portal.bridge.DB.Message.GetByJID(ctx, portal.Key, msgID)
- if err != nil {
- log.Err(err).Msg("Failed to get message from database to check if message is duplicate")
- return
- }
- if existingMsg != nil {
- if existingMsg.Error == database.MsgErrDecryptionFailed {
- resolveType := "sender"
- if evt.UnavailableRequestID != "" {
- resolveType = "phone"
- }
- Analytics.Track(source.MXID, "WhatsApp undecryptable message resolved", map[string]interface{}{
- "messageID": evt.Info.ID,
- "resolveType": resolveType,
- })
- log.Debug().Str("resolved_via", resolveType).Msg("Got decryptable version of previously undecryptable message")
- } else {
- log.Debug().Msg("Not handling duplicate message")
- return
- }
- }
- var editTargetMsg *database.Message
- if msgType == "edit" {
- editTargetID := evt.Message.GetProtocolMessage().GetKey().GetId()
- log.UpdateContext(func(c zerolog.Context) zerolog.Context {
- return c.Str("edit_target_id", editTargetID)
- })
- editTargetMsg, err = portal.bridge.DB.Message.GetByJID(ctx, portal.Key, editTargetID)
- if err != nil {
- log.Err(err).Msg("Failed to get edit target message from database")
- return
- } else if editTargetMsg == nil {
- log.Warn().Msg("Not handling edit: couldn't find edit target")
- return
- } else if editTargetMsg.Type != database.MsgNormal {
- log.Warn().Str("edit_target_db_type", string(editTargetMsg.Type)).
- Msg("Not handling edit: edit target is not a normal message")
- return
- } else if editTargetMsg.Sender.User != evt.Info.Sender.User {
- log.Warn().Stringer("edit_target_sender", editTargetMsg.Sender).
- Msg("Not handling edit: edit was sent by another user")
- return
- }
- evt.Message = evt.Message.GetProtocolMessage().GetEditedMessage()
- }
-
- intent := portal.getMessageIntent(ctx, source, &evt.Info)
- if intent == nil {
- return
- }
- converted := portal.convertMessage(ctx, intent, source, &evt.Info, evt.Message, false)
- if converted != nil {
- isGalleriable := portal.bridge.Config.Bridge.BeeperGalleries &&
- (evt.Message.ImageMessage != nil || evt.Message.VideoMessage != nil) &&
- (portal.galleryCache == nil ||
- (evt.Info.Sender.ToNonAD() == portal.galleryCacheSender &&
- converted.ReplyTo.Equals(portal.galleryCacheReplyTo) &&
- time.Since(portal.galleryCacheStart) < GalleryMaxTime)) &&
- // Captions aren't allowed in galleries (this needs to be checked before the caption is merged)
- converted.Caption == nil &&
- // Images can't be edited
- editTargetMsg == nil
-
- if !historical && portal.IsPrivateChat() && evt.Info.Sender.Device == 0 && converted.ExpiresIn > 0 && portal.ExpirationTime == 0 {
- log.Info().
- Str("timer", converted.ExpiresIn.String()).
- Msg("Implicitly enabling disappearing messages as incoming message is disappearing")
- portal.implicitlyEnableDisappearingMessages(ctx, converted.ExpiresIn)
- }
- if evt.Info.IsIncomingBroadcast() {
- if converted.Extra == nil {
- converted.Extra = map[string]any{}
- }
- converted.Extra["fi.mau.whatsapp.source_broadcast_list"] = evt.Info.Chat.String()
- }
- if portal.bridge.Config.Bridge.CaptionInMessage {
- converted.MergeCaption()
- }
- var eventID id.EventID
- var lastEventID id.EventID
- if existingMsg != nil {
- portal.MarkDisappearing(ctx, existingMsg.MXID, converted.ExpiresIn, evt.Info.Timestamp)
- converted.Content.SetEdit(existingMsg.MXID)
- } else if converted.ReplyTo != nil {
- portal.SetReply(ctx, converted.Content, converted.ReplyTo, false)
- }
- dbMsgType := database.MsgNormal
- if editTargetMsg != nil {
- dbMsgType = database.MsgEdit
- converted.Content.SetEdit(editTargetMsg.MXID)
- }
- galleryStarted := false
- var galleryPart int
- if isGalleriable {
- if portal.galleryCache == nil {
- portal.startGallery(evt, converted)
- galleryStarted = true
- } else {
- galleryPart = portal.extendGallery(converted)
- dbMsgType = database.MsgBeeperGallery
- }
- } else if editTargetMsg == nil {
- // Stop collecting a gallery (except if it's an edit)
- portal.stopGallery()
- }
- var resp *mautrix.RespSendEvent
- resp, err = portal.sendMessage(ctx, converted.Intent, converted.Type, converted.Content, converted.Extra, evt.Info.Timestamp.UnixMilli())
- if err != nil {
- log.Err(err).Msg("Failed to send WhatsApp message to Matrix")
- } else {
- if editTargetMsg == nil {
- portal.MarkDisappearing(ctx, resp.EventID, converted.ExpiresIn, evt.Info.Timestamp)
- }
- eventID = resp.EventID
- lastEventID = eventID
- if galleryStarted {
- portal.galleryCacheRootEvent = eventID
- } else if galleryPart != 0 {
- eventID = portal.galleryCacheRootEvent
- }
- }
- // TODO figure out how to handle captions with undecryptable messages turning decryptable
- if converted.Caption != nil && existingMsg == nil && editTargetMsg == nil {
- resp, err = portal.sendMessage(ctx, converted.Intent, converted.Type, converted.Caption, nil, evt.Info.Timestamp.UnixMilli())
- if err != nil {
- log.Err(err).Msg("Failed to send caption of WhatsApp message to Matrix")
- } else {
- portal.MarkDisappearing(ctx, resp.EventID, converted.ExpiresIn, evt.Info.Timestamp)
- lastEventID = resp.EventID
- }
- }
- if converted.MultiEvent != nil && existingMsg == nil && editTargetMsg == nil {
- for index, subEvt := range converted.MultiEvent {
- resp, err = portal.sendMessage(ctx, converted.Intent, converted.Type, subEvt, nil, evt.Info.Timestamp.UnixMilli())
- if err != nil {
- log.Err(err).Int("part_number", index+1).Msg("Failed to send sub-event of WhatsApp message to Matrix")
- } else {
- portal.MarkDisappearing(ctx, resp.EventID, converted.ExpiresIn, evt.Info.Timestamp)
- lastEventID = resp.EventID
- }
- }
- }
- if source.MXID == intent.UserID && portal.bridge.Config.Homeserver.Software != bridgeconfig.SoftwareHungry {
- // There are some edge cases (like call notices) where previous messages aren't marked as read
- // when the user sends a message from another device, so just mark the new message as read to be safe.
- // Hungryserv does this automatically, so the bridge doesn't need to do it manually.
- err = intent.SetReadMarkers(ctx, portal.MXID, source.makeReadMarkerContent(lastEventID, true))
- if err != nil {
- log.Warn().Err(err).Stringer("last_event_id", lastEventID).
- Msg("Failed to mark last message as read after sending")
- }
- }
- if len(eventID) != 0 {
- portal.finishHandling(ctx, existingMsg, &evt.Info, eventID, intent.UserID, dbMsgType, galleryPart, converted.Error)
- }
- } else if msgType == "reaction" || msgType == "encrypted reaction" {
- if evt.Message.GetEncReactionMessage() != nil {
- log.UpdateContext(func(c zerolog.Context) zerolog.Context {
- return c.Str("reaction_target_id", evt.Message.GetEncReactionMessage().GetTargetMessageKey().GetId())
- })
- decryptedReaction, err := source.Client.DecryptReaction(evt)
- if err != nil {
- log.Err(err).Msg("Failed to decrypt reaction")
- } else {
- portal.HandleMessageReaction(ctx, intent, source, &evt.Info, decryptedReaction, existingMsg)
- }
- } else {
- portal.HandleMessageReaction(ctx, intent, source, &evt.Info, evt.Message.GetReactionMessage(), existingMsg)
- }
- } else if msgType == "revoke" {
- portal.HandleMessageRevoke(ctx, source, &evt.Info, evt.Message.GetProtocolMessage().GetKey())
- if existingMsg != nil {
- _, _ = portal.MainIntent().RedactEvent(ctx, portal.MXID, existingMsg.MXID, mautrix.ReqRedact{
- Reason: "The undecryptable message was actually the deletion of another message",
- })
- err = existingMsg.UpdateMXID(ctx, "net.maunium.whatsapp.fake::"+existingMsg.MXID, database.MsgFake, database.MsgNoError)
- if err != nil {
- log.Err(err).Msg("Failed to update message in database after finding undecryptable message was a revoke message")
- }
- }
- } else {
- log.Warn().Any("event_info", evt.Info).Msg("Unhandled message")
- if existingMsg != nil {
- _, _ = portal.MainIntent().RedactEvent(ctx, portal.MXID, existingMsg.MXID, mautrix.ReqRedact{
- Reason: "The undecryptable message contained an unsupported message type",
- })
- err = existingMsg.UpdateMXID(ctx, "net.maunium.whatsapp.fake::"+existingMsg.MXID, database.MsgFake, database.MsgNoError)
- if err != nil {
- log.Err(err).Msg("Failed to update message in database after finding undecryptable message was an unknown message")
- }
- }
- return
- }
- portal.bridge.Metrics.TrackWhatsAppMessage(evt.Info.Timestamp, strings.Split(msgType, " ")[0])
-}
-
-func (portal *Portal) isRecentlyHandled(id types.MessageID, error database.MessageErrorType) bool {
- start := portal.recentlyHandledIndex
- lookingForMsg := recentlyHandledWrapper{id, error}
- for i := start; i != start; i = (i - 1) % recentlyHandledLength {
- if portal.recentlyHandled[i] == lookingForMsg {
- return true
- }
- }
- return false
-}
-
-func (portal *Portal) markHandled(ctx context.Context, msg *database.Message, info *types.MessageInfo, mxid id.EventID, senderMXID id.UserID, isSent, recent bool, msgType database.MessageType, galleryPart int, errType database.MessageErrorType) *database.Message {
- if msg == nil {
- msg = portal.bridge.DB.Message.New()
- msg.Chat = portal.Key
- msg.JID = info.ID
- msg.MXID = mxid
- msg.GalleryPart = galleryPart
- msg.Timestamp = info.Timestamp
- msg.Sender = info.Sender
- msg.SenderMXID = senderMXID
- msg.Sent = isSent
- msg.Type = msgType
- msg.Error = errType
- if info.IsIncomingBroadcast() {
- msg.BroadcastListJID = info.Chat
- }
- err := msg.Insert(ctx)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to insert message to database")
- }
- } else {
- err := msg.UpdateMXID(ctx, mxid, msgType, errType)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to update message in database")
- }
- }
-
- if recent {
- portal.recentlyHandledLock.Lock()
- index := portal.recentlyHandledIndex
- portal.recentlyHandledIndex = (portal.recentlyHandledIndex + 1) % recentlyHandledLength
- portal.recentlyHandledLock.Unlock()
- portal.recentlyHandled[index] = recentlyHandledWrapper{msg.JID, errType}
- }
- return msg
-}
-
-func (portal *Portal) getMessagePuppet(ctx context.Context, user *User, info *types.MessageInfo) (puppet *Puppet) {
- if info.IsFromMe {
- return portal.bridge.GetPuppetByJID(user.JID)
- } else if portal.IsPrivateChat() {
- puppet = portal.bridge.GetPuppetByJID(portal.Key.JID)
- } else if !info.Sender.IsEmpty() {
- puppet = portal.bridge.GetPuppetByJID(info.Sender)
- }
- if puppet == nil {
- zerolog.Ctx(ctx).Warn().Msg("Message doesn't seem to have a valid sender: puppet is nil")
- return nil
- }
- user.EnqueuePortalResync(portal)
- puppet.SyncContact(ctx, user, true, true, "handling message")
- return puppet
-}
-
-func (portal *Portal) getMessageIntent(ctx context.Context, user *User, info *types.MessageInfo) *appservice.IntentAPI {
- if portal.IsNewsletter() && info.Sender == info.Chat {
- return portal.MainIntent()
- }
- puppet := portal.getMessagePuppet(ctx, user, info)
- if puppet == nil {
- return nil
- }
- intent := puppet.IntentFor(portal)
- if !intent.IsCustomPuppet && portal.IsPrivateChat() && info.Sender.User == portal.Key.Receiver.User && portal.Key.Receiver != portal.Key.JID {
- zerolog.Ctx(ctx).Debug().Msg("Not handling message: user doesn't have double puppeting enabled")
- return nil
- }
- return intent
-}
-
-func (portal *Portal) finishHandling(ctx context.Context, existing *database.Message, message *types.MessageInfo, mxid id.EventID, senderMXID id.UserID, msgType database.MessageType, galleryPart int, errType database.MessageErrorType) {
- portal.markHandled(ctx, existing, message, mxid, senderMXID, true, true, msgType, galleryPart, errType)
- portal.sendDeliveryReceipt(ctx, mxid)
- logEvt := zerolog.Ctx(ctx).Debug().
- Stringer("matrix_event_id", mxid)
- if errType != database.MsgNoError {
- logEvt.Str("error_type", string(errType))
- }
- logEvt.Msg("Successfully handled WhatsApp message")
-}
-
-func (portal *Portal) kickExtraUsers(ctx context.Context, participantMap map[types.JID]bool) {
- members, err := portal.MainIntent().JoinedMembers(ctx, portal.MXID)
- if err != nil {
- zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get member list to kick extra users")
- return
- }
- for member := range members.Joined {
- jid, ok := portal.bridge.ParsePuppetMXID(member)
- if ok {
- _, shouldBePresent := participantMap[jid]
- if !shouldBePresent {
- _, err = portal.MainIntent().KickUser(ctx, portal.MXID, &mautrix.ReqKickUser{
- UserID: member,
- Reason: "User had left this WhatsApp chat",
- })
- if err != nil {
- zerolog.Ctx(ctx).Warn().Err(err).
- Stringer("user_id", member).
- Msg("Failed to kick extra user from room")
- }
- }
- }
- }
-}
-
-//func (portal *Portal) SyncBroadcastRecipients(source *User, metadata *whatsapp.BroadcastListInfo) {
-// participantMap := make(map[whatsapp.JID]bool)
-// for _, recipient := range metadata.Recipients {
-// participantMap[recipient.JID] = true
-//
-// puppet := portal.bridge.GetPuppetByJID(recipient.JID)
-// puppet.SyncContactIfNecessary(source)
-// err := puppet.DefaultIntent().EnsureJoined(portal.MXID)
-// if err != nil {
-// portal.log.Warnfln("Failed to make puppet of %s join %s: %v", recipient.JID, portal.MXID, err)
-// }
-// }
-// portal.kickExtraUsers(participantMap)
-//}
-
-func (portal *Portal) syncParticipant(ctx context.Context, source *User, participant types.GroupParticipant, puppet *Puppet, user *User, wg *sync.WaitGroup) {
- defer func() {
- wg.Done()
- if err := recover(); err != nil {
- zerolog.Ctx(ctx).Error().
- Bytes(zerolog.ErrorStackFieldName, debug.Stack()).
- Any(zerolog.ErrorFieldName, err).
- Stringer("participant_jid", participant.JID).
- Msg("Syncing participant panicked")
- }
- }()
- puppet.SyncContact(ctx, source, true, false, "group participant")
- if portal.MXID != "" {
- if user != nil && user != source {
- portal.ensureUserInvited(ctx, user)
- }
- if user == nil || !puppet.IntentFor(portal).IsCustomPuppet {
- err := puppet.IntentFor(portal).EnsureJoined(ctx, portal.MXID)
- if err != nil {
- zerolog.Ctx(ctx).Warn().Err(err).
- Stringer("participant_jid", participant.JID).
- Msg("Failed to make ghost user join portal")
- }
- }
- }
-}
-
-func (portal *Portal) SyncParticipants(ctx context.Context, source *User, metadata *types.GroupInfo) ([]id.UserID, *event.PowerLevelsEventContent) {
- if portal.IsNewsletter() {
- return nil, nil
- }
- changed := false
- var levels *event.PowerLevelsEventContent
- var err error
- if portal.MXID != "" {
- levels, err = portal.MainIntent().PowerLevels(ctx, portal.MXID)
- }
- if levels == nil || err != nil {
- levels = portal.GetBasePowerLevels()
- changed = true
- }
- changed = portal.applyPowerLevelFixes(levels) || changed
- var wg sync.WaitGroup
- wg.Add(len(metadata.Participants))
- participantMap := make(map[types.JID]bool)
- userIDs := make([]id.UserID, 0, len(metadata.Participants))
- log := zerolog.Ctx(ctx)
- for _, participant := range metadata.Participants {
- if participant.JID.IsEmpty() || participant.JID.Server != types.DefaultUserServer {
- wg.Done()
- // TODO handle lids
- continue
- }
- log.Debug().
- Stringer("participant_jid", participant.JID).
- Bool("is_admin", participant.IsAdmin).
- Msg("Syncing participant")
- participantMap[participant.JID] = true
- puppet := portal.bridge.GetPuppetByJID(participant.JID)
- user := portal.bridge.GetUserByJID(participant.JID)
- if portal.bridge.Config.Bridge.ParallelMemberSync {
- go portal.syncParticipant(ctx, source, participant, puppet, user, &wg)
- } else {
- portal.syncParticipant(ctx, source, participant, puppet, user, &wg)
- }
-
- expectedLevel := 0
- if participant.IsSuperAdmin {
- expectedLevel = 95
- } else if participant.IsAdmin {
- expectedLevel = 50
- }
- changed = levels.EnsureUserLevel(puppet.MXID, expectedLevel) || changed
- if user != nil {
- userIDs = append(userIDs, user.MXID)
- changed = levels.EnsureUserLevel(user.MXID, expectedLevel) || changed
- }
- if user == nil || puppet.CustomMXID != user.MXID {
- userIDs = append(userIDs, puppet.MXID)
- }
- }
- if portal.MXID != "" {
- if changed {
- _, err = portal.MainIntent().SetPowerLevels(ctx, portal.MXID, levels)
- if err != nil {
- log.Err(err).Msg("Failed to update power levels in room")
- }
- }
- portal.kickExtraUsers(ctx, participantMap)
- }
- wg.Wait()
- log.Debug().Msg("Participant sync completed")
- return userIDs, levels
-}
-
-func reuploadAvatar(ctx context.Context, intent *appservice.IntentAPI, url string) (id.ContentURI, error) {
- getResp, err := http.DefaultClient.Get(url)
- if err != nil {
- return id.ContentURI{}, fmt.Errorf("failed to download avatar: %w", err)
- }
- data, err := io.ReadAll(getResp.Body)
- _ = getResp.Body.Close()
- if err != nil {
- return id.ContentURI{}, fmt.Errorf("failed to read avatar bytes: %w", err)
- }
-
- resp, err := intent.UploadBytes(ctx, data, http.DetectContentType(data))
- if err != nil {
- return id.ContentURI{}, fmt.Errorf("failed to upload avatar to Matrix: %w", err)
- }
- return resp.ContentURI, nil
-}
-
-func (user *User) reuploadAvatarDirectPath(ctx context.Context, intent *appservice.IntentAPI, directPath string) (id.ContentURI, error) {
- data, err := user.Client.DownloadMediaWithPath(directPath, nil, nil, nil, 0, "", "")
- if err != nil {
- return id.ContentURI{}, fmt.Errorf("failed to download avatar: %w", err)
- }
- resp, err := intent.UploadBytes(ctx, data, http.DetectContentType(data))
- if err != nil {
- return id.ContentURI{}, fmt.Errorf("failed to upload avatar to Matrix: %w", err)
- }
- return resp.ContentURI, nil
-}
-
-func (user *User) updateAvatar(ctx context.Context, jid types.JID, isCommunity bool, avatarID *string, avatarURL *id.ContentURI, avatarSet *bool, intent *appservice.IntentAPI) bool {
- currentID := ""
- if *avatarSet && *avatarID != "remove" && *avatarID != "unauthorized" {
- currentID = *avatarID
- }
- avatar, err := user.Client.GetProfilePictureInfo(jid, &whatsmeow.GetProfilePictureParams{
- Preview: false,
- ExistingID: currentID,
- IsCommunity: isCommunity,
- })
- log := zerolog.Ctx(ctx)
- if errors.Is(err, whatsmeow.ErrProfilePictureUnauthorized) {
- if *avatarID == "" {
- *avatarID = "unauthorized"
- *avatarSet = false
- return true
- }
- return false
- } else if errors.Is(err, whatsmeow.ErrProfilePictureNotSet) {
- avatar = &types.ProfilePictureInfo{ID: "remove"}
- if avatar.ID == *avatarID && *avatarSet {
- return false
- }
- *avatarID = avatar.ID
- *avatarURL = id.ContentURI{}
- return true
- } else if err != nil {
- log.Err(err).Msg("Failed to get avatar URL")
- return false
- } else if avatar == nil {
- // Avatar hasn't changed
- return false
- }
- if avatar.ID == *avatarID && *avatarSet {
- return false
- } else if len(avatar.URL) == 0 && len(avatar.DirectPath) == 0 {
- log.Warn().Msg("Didn't get URL in response to avatar query")
- return false
- } else if avatar.ID != *avatarID || avatarURL.IsEmpty() {
- var url id.ContentURI
- if len(avatar.URL) > 0 {
- url, err = reuploadAvatar(ctx, intent, avatar.URL)
- if err != nil {
- log.Err(err).Msg("Failed to reupload avatar")
- return false
- }
- } else {
- url, err = user.reuploadAvatarDirectPath(ctx, intent, avatar.DirectPath)
- if err != nil {
- log.Err(err).Msg("Failed to reupload avatar")
- return false
- }
- }
- *avatarURL = url
- }
- log.Debug().Str("old_avatar_id", *avatarID).Str("new_avatar_id", avatar.ID).Msg("Updated avatar")
- *avatarID = avatar.ID
- *avatarSet = false
- return true
-}
-
-func (portal *Portal) UpdateNewsletterAvatar(ctx context.Context, user *User, meta *types.NewsletterMetadata) bool {
- portal.avatarLock.Lock()
- defer portal.avatarLock.Unlock()
- var picID string
- picture := meta.ThreadMeta.Picture
- if picture == nil {
- picID = meta.ThreadMeta.Preview.ID
- } else {
- picID = picture.ID
- }
- if picID == "" {
- picID = "remove"
- }
- if portal.Avatar == picID && portal.AvatarSet {
- return false
- }
- log := zerolog.Ctx(ctx)
- if picID == "remove" {
- portal.AvatarURL = id.ContentURI{}
- } else if portal.Avatar != picID || portal.AvatarURL.IsEmpty() {
- var err error
- if picture == nil {
- meta, err = user.Client.GetNewsletterInfo(portal.Key.JID)
- if err != nil {
- log.Err(err).Msg("Failed to fetch full res avatar info for newsletter")
- return false
- }
- picture = meta.ThreadMeta.Picture
- if picture == nil {
- log.Warn().Msg("Didn't get full res avatar info for newsletter")
- return false
- }
- picID = picture.ID
- }
- portal.AvatarURL, err = user.reuploadAvatarDirectPath(ctx, portal.MainIntent(), picture.DirectPath)
- if err != nil {
- log.Err(err).Msg("Failed to reupload newsletter avatar")
- return false
- }
- }
- portal.Avatar = picID
- portal.AvatarSet = false
- return portal.setRoomAvatar(ctx, true, types.EmptyJID, true)
-}
-
-func (portal *Portal) UpdateAvatar(ctx context.Context, user *User, setBy types.JID, updateInfo bool) bool {
- if portal.IsNewsletter() {
- return false
- }
- portal.avatarLock.Lock()
- defer portal.avatarLock.Unlock()
- changed := user.updateAvatar(ctx, portal.Key.JID, portal.IsParent, &portal.Avatar, &portal.AvatarURL, &portal.AvatarSet, portal.MainIntent())
- return portal.setRoomAvatar(ctx, changed, setBy, updateInfo)
-}
-
-func (portal *Portal) setRoomAvatar(ctx context.Context, changed bool, setBy types.JID, updateInfo bool) bool {
- log := zerolog.Ctx(ctx)
- if !changed || portal.Avatar == "unauthorized" {
- if changed || updateInfo {
- err := portal.Update(ctx)
- if err != nil {
- log.Err(err).Msg("Failed to save portal in setRoomAvatar")
- }
- }
- return changed
- }
-
- if len(portal.MXID) > 0 {
- intent := portal.MainIntent()
- if !setBy.IsEmpty() && setBy.Server == types.DefaultUserServer {
- intent = portal.bridge.GetPuppetByJID(setBy).IntentFor(portal)
- }
- _, err := intent.SetRoomAvatar(ctx, portal.MXID, portal.AvatarURL)
- if errors.Is(err, mautrix.MForbidden) && intent != portal.MainIntent() {
- _, err = portal.MainIntent().SetRoomAvatar(ctx, portal.MXID, portal.AvatarURL)
- }
- if err != nil {
- log.Err(err).Msg("Failed to set room avatar")
- return true
- } else {
- portal.AvatarSet = true
- }
- }
- if updateInfo {
- portal.UpdateBridgeInfo(ctx)
- err := portal.Update(ctx)
- if err != nil {
- log.Err(err).Msg("Failed to save portal in setRoomAvatar")
- }
- portal.updateChildRooms(ctx)
- }
- return true
-}
-
-func (portal *Portal) UpdateName(ctx context.Context, name string, setBy types.JID, updateInfo bool) bool {
- if name == "" && portal.IsBroadcastList() {
- name = UnnamedBroadcastName
- }
- if portal.Name == name && (portal.NameSet || len(portal.MXID) == 0 || !portal.shouldSetDMRoomMetadata()) {
- return false
- }
- log := zerolog.Ctx(ctx)
- log.Debug().Str("old_name", portal.Name).Str("new_name", name).Msg("Updating room name")
- portal.Name = name
- portal.NameSet = false
- if updateInfo {
- defer func() {
- err := portal.Update(ctx)
- if err != nil {
- log.Err(err).Msg("Failed to save portal after updating name")
- }
- }()
- }
- if len(portal.MXID) == 0 {
- return true
- }
- if !portal.shouldSetDMRoomMetadata() {
- // TODO only do this if updateInfo?
- portal.UpdateBridgeInfo(ctx)
- return true
- }
- intent := portal.MainIntent()
- if !setBy.IsEmpty() && setBy.Server == types.DefaultUserServer {
- intent = portal.bridge.GetPuppetByJID(setBy).IntentFor(portal)
- }
- _, err := intent.SetRoomName(ctx, portal.MXID, name)
- if errors.Is(err, mautrix.MForbidden) && intent != portal.MainIntent() {
- _, err = portal.MainIntent().SetRoomName(ctx, portal.MXID, name)
- }
- if err != nil {
- log.Err(err).Msg("Failed to set room name")
- } else {
- portal.NameSet = true
- if updateInfo {
- portal.UpdateBridgeInfo(ctx)
- portal.updateChildRooms(ctx)
- }
- }
- return true
-}
-
-func (portal *Portal) UpdateTopic(ctx context.Context, topic string, setBy types.JID, updateInfo bool) bool {
- if portal.Topic == topic && (portal.TopicSet || len(portal.MXID) == 0) {
- return false
- }
- log := zerolog.Ctx(ctx)
- log.Debug().Str("old_topic", portal.Topic).Str("new_topic", topic).Msg("Updating topic")
- portal.Topic = topic
- portal.TopicSet = false
- if updateInfo {
- defer func() {
- err := portal.Update(ctx)
- if err != nil {
- log.Err(err).Msg("Failed to save portal after updating topic")
- }
- }()
- }
-
- intent := portal.MainIntent()
- if !setBy.IsEmpty() && setBy.Server == types.DefaultUserServer {
- intent = portal.bridge.GetPuppetByJID(setBy).IntentFor(portal)
- }
- _, err := intent.SetRoomTopic(ctx, portal.MXID, topic)
- if errors.Is(err, mautrix.MForbidden) && intent != portal.MainIntent() {
- _, err = portal.MainIntent().SetRoomTopic(ctx, portal.MXID, topic)
- }
- if err != nil {
- log.Err(err).Msg("Failed to set room topic")
- } else {
- portal.TopicSet = true
- if updateInfo {
- portal.UpdateBridgeInfo(ctx)
- }
- }
- return true
-}
-
-func newsletterToGroupInfo(meta *types.NewsletterMetadata) *types.GroupInfo {
- var out types.GroupInfo
- out.JID = meta.ID
- out.Name = meta.ThreadMeta.Name.Text
- out.NameSetAt = meta.ThreadMeta.Name.UpdateTime.Time
- out.Topic = meta.ThreadMeta.Description.Text
- out.TopicSetAt = meta.ThreadMeta.Description.UpdateTime.Time
- out.TopicID = meta.ThreadMeta.Description.ID
- out.GroupCreated = meta.ThreadMeta.CreationTime.Time
- out.IsAnnounce = true
- out.IsLocked = true
- out.IsIncognito = true
- return &out
-}
-
-func (portal *Portal) UpdateParentGroup(ctx context.Context, source *User, parent types.JID, updateInfo bool) bool {
- portal.parentGroupUpdateLock.Lock()
- defer portal.parentGroupUpdateLock.Unlock()
- if portal.ParentGroup != parent {
- zerolog.Ctx(ctx).Debug().
- Stringer("old_parent_group", portal.ParentGroup).
- Stringer("new_parent_group", parent).
- Msg("Updating parent group")
- portal.updateCommunitySpace(ctx, source, false, false)
- portal.ParentGroup = parent
- portal.parentPortal = nil
- portal.InSpace = false
- portal.updateCommunitySpace(ctx, source, true, false)
- if updateInfo {
- portal.UpdateBridgeInfo(ctx)
- err := portal.Update(ctx)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal after updating parent group")
- }
- }
- return true
- } else if !portal.ParentGroup.IsEmpty() && !portal.InSpace {
- return portal.updateCommunitySpace(ctx, source, true, updateInfo)
- }
- return false
-}
-
-func (portal *Portal) UpdateMetadata(ctx context.Context, user *User, groupInfo *types.GroupInfo, newsletterMetadata *types.NewsletterMetadata) bool {
- if portal.IsPrivateChat() {
- return false
- } else if portal.IsStatusBroadcastList() {
- update := false
- update = portal.UpdateName(ctx, StatusBroadcastName, types.EmptyJID, false) || update
- update = portal.UpdateTopic(ctx, StatusBroadcastTopic, types.EmptyJID, false) || update
- return update
- } else if portal.IsBroadcastList() {
- update := false
- //broadcastMetadata, err := user.Conn.GetBroadcastMetadata(portal.Key.JID)
- //if err == nil && broadcastMetadata.Status == 200 {
- // portal.SyncBroadcastRecipients(user, broadcastMetadata)
- // update = portal.UpdateName(broadcastMetadata.Name, "", nil, false) || update
- //} else {
- // user.Conn.Store.ContactsLock.RLock()
- // contact, _ := user.Conn.Store.Contacts[portal.Key.JID]
- // user.Conn.Store.ContactsLock.RUnlock()
- // update = portal.UpdateName(contact.Name, "", nil, false) || update
- //}
- //update = portal.UpdateTopic(BroadcastTopic, "", nil, false) || update
- return update
- }
- if groupInfo == nil && portal.IsNewsletter() {
- if newsletterMetadata == nil {
- var err error
- newsletterMetadata, err = user.Client.GetNewsletterInfo(portal.Key.JID)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to get newsletter info")
- return false
- }
- }
- groupInfo = newsletterToGroupInfo(newsletterMetadata)
- }
- if groupInfo == nil {
- var err error
- groupInfo, err = user.Client.GetGroupInfo(portal.Key.JID)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to get group info")
- return false
- }
- }
-
- portal.SyncParticipants(ctx, user, groupInfo)
- update := false
- update = portal.UpdateName(ctx, groupInfo.Name, groupInfo.NameSetBy, false) || update
- update = portal.UpdateTopic(ctx, groupInfo.Topic, groupInfo.TopicSetBy, false) || update
- update = portal.UpdateParentGroup(ctx, user, groupInfo.LinkedParentJID, false) || update
- if portal.ExpirationTime != groupInfo.DisappearingTimer {
- update = true
- portal.ExpirationTime = groupInfo.DisappearingTimer
- }
- if portal.IsParent != groupInfo.IsParent {
- if portal.MXID != "" {
- zerolog.Ctx(ctx).Warn().Bool("new_is_parent", groupInfo.IsParent).Msg("Existing group changed is_parent status")
- }
- portal.IsParent = groupInfo.IsParent
- update = true
- }
-
- portal.RestrictMessageSending(ctx, groupInfo.IsAnnounce)
- portal.RestrictMetadataChanges(ctx, groupInfo.IsLocked)
- if newsletterMetadata != nil && newsletterMetadata.ViewerMeta != nil {
- portal.PromoteNewsletterUser(ctx, user, newsletterMetadata.ViewerMeta.Role)
- }
-
- return update
-}
-
-func (portal *Portal) ensureUserInvited(ctx context.Context, user *User) bool {
- return user.ensureInvited(ctx, portal.MainIntent(), portal.MXID, portal.IsPrivateChat())
-}
-
-func (portal *Portal) UpdateMatrixRoom(ctx context.Context, user *User, groupInfo *types.GroupInfo, newsletterMetadata *types.NewsletterMetadata) bool {
- if len(portal.MXID) == 0 {
- return false
- }
- log := zerolog.Ctx(ctx).With().
- Str("action", "update matrix room").
- Str("portal_key", portal.Key.String()).
- Stringer("source_mxid", user.MXID).
- Logger()
- ctx = log.WithContext(ctx)
- log.Info().Msg("Syncing portal")
-
- portal.ensureUserInvited(ctx, user)
- go portal.addToPersonalSpace(ctx, user)
-
- if groupInfo == nil && newsletterMetadata != nil {
- groupInfo = newsletterToGroupInfo(newsletterMetadata)
- }
-
- update := false
- update = portal.UpdateMetadata(ctx, user, groupInfo, newsletterMetadata) || update
- if !portal.IsPrivateChat() && !portal.IsBroadcastList() && !portal.IsNewsletter() {
- update = portal.UpdateAvatar(ctx, user, types.EmptyJID, false) || update
- } else if newsletterMetadata != nil {
- update = portal.UpdateNewsletterAvatar(ctx, user, newsletterMetadata) || update
- }
- if update || portal.LastSync.Add(24*time.Hour).Before(time.Now()) {
- portal.LastSync = time.Now()
- err := portal.Update(ctx)
- if err != nil {
- log.Err(err).Msg("Failed to save portal after updating")
- }
- portal.UpdateBridgeInfo(ctx)
- portal.updateChildRooms(ctx)
- }
- return true
-}
-
-func (portal *Portal) GetBasePowerLevels() *event.PowerLevelsEventContent {
- anyone := 0
- nope := 99
- invite := 50
- if portal.bridge.Config.Bridge.AllowUserInvite {
- invite = 0
- }
- return &event.PowerLevelsEventContent{
- UsersDefault: anyone,
- EventsDefault: anyone,
- RedactPtr: &anyone,
- StateDefaultPtr: &nope,
- BanPtr: &nope,
- InvitePtr: &invite,
- Users: map[id.UserID]int{
- portal.MainIntent().UserID: 100,
- portal.bridge.Bot.UserID: 100,
- },
- Events: map[string]int{
- event.StateRoomName.Type: anyone,
- event.StateRoomAvatar.Type: anyone,
- event.StateTopic.Type: anyone,
- event.EventReaction.Type: anyone,
- event.EventRedaction.Type: anyone,
- TypeMSC3381PollResponse.Type: anyone,
- },
- }
-}
-
-func (portal *Portal) applyPowerLevelFixes(levels *event.PowerLevelsEventContent) bool {
- changed := false
- changed = levels.EnsureEventLevel(event.EventReaction, 0) || changed
- changed = levels.EnsureEventLevel(event.EventRedaction, 0) || changed
- changed = levels.EnsureEventLevel(TypeMSC3381PollResponse, 0) || changed
- if portal.IsPrivateChat() {
- changed = levels.EnsureUserLevel(portal.bridge.Bot.UserID, 100) || changed
- }
- return changed
-}
-
-func (portal *Portal) ChangeAdminStatus(ctx context.Context, jids []types.JID, setAdmin bool) id.EventID {
- levels, err := portal.MainIntent().PowerLevels(ctx, portal.MXID)
- if err != nil {
- levels = portal.GetBasePowerLevels()
- }
- newLevel := 0
- if setAdmin {
- newLevel = 50
- }
- changed := portal.applyPowerLevelFixes(levels)
- for _, jid := range jids {
- if jid.Server != types.DefaultUserServer {
- // TODO handle lids
- continue
- }
- puppet := portal.bridge.GetPuppetByJID(jid)
- changed = levels.EnsureUserLevel(puppet.MXID, newLevel) || changed
-
- user := portal.bridge.GetUserByJID(jid)
- if user != nil {
- changed = levels.EnsureUserLevel(user.MXID, newLevel) || changed
- }
- }
- if changed {
- resp, err := portal.MainIntent().SetPowerLevels(ctx, portal.MXID, levels)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to set power levels")
- } else {
- return resp.EventID
- }
- }
- return ""
-}
-
-func (portal *Portal) RestrictMessageSending(ctx context.Context, restrict bool) id.EventID {
- levels, err := portal.MainIntent().PowerLevels(ctx, portal.MXID)
- if err != nil {
- levels = portal.GetBasePowerLevels()
- }
-
- newLevel := 0
- if restrict {
- newLevel = 50
- }
-
- changed := portal.applyPowerLevelFixes(levels)
- if levels.EventsDefault == newLevel && !changed {
- return ""
- }
-
- levels.EventsDefault = newLevel
- resp, err := portal.MainIntent().SetPowerLevels(ctx, portal.MXID, levels)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to set power levels")
- return ""
- } else {
- return resp.EventID
- }
-}
-
-func (portal *Portal) PromoteNewsletterUser(ctx context.Context, user *User, role types.NewsletterRole) id.EventID {
- levels, err := portal.MainIntent().PowerLevels(ctx, portal.MXID)
- if err != nil {
- levels = portal.GetBasePowerLevels()
- }
-
- newLevel := 0
- switch role {
- case types.NewsletterRoleAdmin:
- newLevel = 50
- case types.NewsletterRoleOwner:
- newLevel = 95
- }
-
- changed := portal.applyPowerLevelFixes(levels)
- changed = levels.EnsureUserLevel(user.MXID, newLevel) || changed
- if !changed {
- return ""
- }
-
- resp, err := portal.MainIntent().SetPowerLevels(ctx, portal.MXID, levels)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to set power levels")
- return ""
- } else {
- return resp.EventID
- }
-}
-
-func (portal *Portal) RestrictMetadataChanges(ctx context.Context, restrict bool) id.EventID {
- levels, err := portal.MainIntent().PowerLevels(ctx, portal.MXID)
- if err != nil {
- levels = portal.GetBasePowerLevels()
- }
- newLevel := 0
- if restrict {
- newLevel = 50
- }
- changed := portal.applyPowerLevelFixes(levels)
- changed = levels.EnsureEventLevel(event.StateRoomName, newLevel) || changed
- changed = levels.EnsureEventLevel(event.StateRoomAvatar, newLevel) || changed
- changed = levels.EnsureEventLevel(event.StateTopic, newLevel) || changed
- if changed {
- resp, err := portal.MainIntent().SetPowerLevels(ctx, portal.MXID, levels)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to set power levels")
- } else {
- return resp.EventID
- }
- }
- return ""
-}
-
-func (portal *Portal) getBridgeInfoStateKey() string {
- return fmt.Sprintf("net.maunium.whatsapp://whatsapp/%s", portal.Key.JID)
-}
-
-func (portal *Portal) getBridgeInfo() (string, event.BridgeEventContent) {
- bridgeInfo := event.BridgeEventContent{
- BridgeBot: portal.bridge.Bot.UserID,
- Creator: portal.MainIntent().UserID,
- Protocol: event.BridgeInfoSection{
- ID: "whatsapp",
- DisplayName: "WhatsApp",
- AvatarURL: portal.bridge.Config.AppService.Bot.ParsedAvatar.CUString(),
- ExternalURL: "https://www.whatsapp.com/",
- },
- Channel: event.BridgeInfoSection{
- ID: portal.Key.JID.String(),
- DisplayName: portal.Name,
- AvatarURL: portal.AvatarURL.CUString(),
- },
- }
- if parent := portal.GetParentPortal(); parent != nil {
- bridgeInfo.Network = &event.BridgeInfoSection{
- ID: parent.Key.JID.String(),
- DisplayName: parent.Name,
- AvatarURL: parent.AvatarURL.CUString(),
- }
- }
- return portal.getBridgeInfoStateKey(), bridgeInfo
-}
-
-func (portal *Portal) UpdateBridgeInfo(ctx context.Context) {
- log := zerolog.Ctx(ctx)
- if len(portal.MXID) == 0 {
- log.Debug().Msg("Not updating bridge info: no Matrix room created")
- return
- }
- log.Debug().Msg("Updating bridge info...")
- stateKey, content := portal.getBridgeInfo()
- _, err := portal.MainIntent().SendStateEvent(ctx, portal.MXID, event.StateBridge, stateKey, content)
- if err != nil {
- log.Warn().Err(err).Msg("Failed to update m.bridge info")
- }
- // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
- _, err = portal.MainIntent().SendStateEvent(ctx, portal.MXID, event.StateHalfShotBridge, stateKey, content)
- if err != nil {
- log.Warn().Err(err).Msg("Failed to update uk.half-shot.bridge info")
- }
-}
-
-func (portal *Portal) updateChildRooms(ctx context.Context) {
- if !portal.IsParent {
- return
- }
- children := portal.bridge.GetAllByParentGroup(portal.Key.JID)
- for _, child := range children {
- changed := child.updateCommunitySpace(ctx, nil, true, false)
- // TODO set updateInfo to true instead of updating manually?
- child.UpdateBridgeInfo(ctx)
- if changed {
- // TODO is this saving the wrong portal?
- err := portal.Update(ctx)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to save portal after updating")
- }
- }
- }
-}
-
-func (portal *Portal) shouldSetDMRoomMetadata() bool {
- return !portal.IsPrivateChat() ||
- portal.bridge.Config.Bridge.PrivateChatPortalMeta == "always" ||
- (portal.IsEncrypted() && portal.bridge.Config.Bridge.PrivateChatPortalMeta != "never")
-}
-
-func (portal *Portal) GetEncryptionEventContent() (evt *event.EncryptionEventContent) {
- evt = &event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1}
- if rot := portal.bridge.Config.Bridge.Encryption.Rotation; rot.EnableCustom {
- evt.RotationPeriodMillis = rot.Milliseconds
- evt.RotationPeriodMessages = rot.Messages
- }
- return
-}
-
-func (portal *Portal) CreateMatrixRoom(ctx context.Context, user *User, groupInfo *types.GroupInfo, newsletterMetadata *types.NewsletterMetadata, isFullInfo, backfill bool) error {
- portal.roomCreateLock.Lock()
- defer portal.roomCreateLock.Unlock()
- if len(portal.MXID) > 0 {
- return nil
- }
- log := zerolog.Ctx(ctx).With().
- Str("action", "create matrix room").
- Str("portal_key", portal.Key.String()).
- Stringer("source_mxid", user.MXID).
- Logger()
- ctx = log.WithContext(ctx)
-
- intent := portal.MainIntent()
- if err := intent.EnsureRegistered(ctx); err != nil {
- return err
- }
-
- log.Info().Msg("Creating Matrix room")
-
- //var broadcastMetadata *types.BroadcastListInfo
- if portal.IsPrivateChat() {
- puppet := portal.bridge.GetPuppetByJID(portal.Key.JID)
- puppet.SyncContact(ctx, user, true, false, "creating private chat portal")
- portal.Name = puppet.Displayname
- portal.AvatarURL = puppet.AvatarURL
- portal.Avatar = puppet.Avatar
- portal.Topic = PrivateChatTopic
- } else if portal.IsStatusBroadcastList() {
- if !portal.bridge.Config.Bridge.EnableStatusBroadcast {
- log.Debug().Msg("Status bridging is disabled in config, not creating room after all")
- return ErrStatusBroadcastDisabled
- }
- portal.Name = StatusBroadcastName
- portal.Topic = StatusBroadcastTopic
- } else if portal.IsBroadcastList() {
- //var err error
- //broadcastMetadata, err = user.Conn.GetBroadcastMetadata(portal.Key.JID)
- //if err == nil && broadcastMetadata.Status == 200 {
- // portal.Name = broadcastMetadata.Name
- //} else {
- // user.Conn.Store.ContactsLock.RLock()
- // contact, _ := user.Conn.Store.Contacts[portal.Key.JID]
- // user.Conn.Store.ContactsLock.RUnlock()
- // portal.Name = contact.Name
- //}
- //if len(portal.Name) == 0 {
- // portal.Name = UnnamedBroadcastName
- //}
- //portal.Topic = BroadcastTopic
- log.Debug().Msg("Broadcast list is not yet supported, not creating room after all")
- return fmt.Errorf("broadcast list bridging is currently not supported")
- } else {
- if portal.IsNewsletter() {
- if newsletterMetadata == nil {
- var err error
- newsletterMetadata, err = user.Client.GetNewsletterInfo(portal.Key.JID)
- if err != nil {
- return err
- }
- }
- if groupInfo == nil {
- groupInfo = newsletterToGroupInfo(newsletterMetadata)
- }
- } else if groupInfo == nil || !isFullInfo {
- foundInfo, err := user.Client.GetGroupInfo(portal.Key.JID)
-
- // Ensure that the user is actually a participant in the conversation
- // before creating the matrix room
- if errors.Is(err, whatsmeow.ErrNotInGroup) {
- log.Debug().Msg("Skipping creating room because the user is not a participant")
- err = user.bridge.DB.BackfillQueue.DeleteAllForPortal(ctx, user.MXID, portal.Key)
- if err != nil {
- log.Err(err).Msg("Failed to delete backfill queue for portal")
- }
- err = user.bridge.DB.HistorySync.DeleteAllMessagesForPortal(ctx, user.MXID, portal.Key)
- if err != nil {
- log.Err(err).Msg("Failed to delete historical messages for portal")
- }
- return err
- } else if err != nil {
- log.Err(err).Msg("Failed to get group info")
- } else {
- groupInfo = foundInfo
- isFullInfo = true
- }
- }
- if groupInfo != nil {
- portal.Name = groupInfo.Name
- portal.Topic = groupInfo.Topic
- portal.IsParent = groupInfo.IsParent
- portal.ParentGroup = groupInfo.LinkedParentJID
- if groupInfo.IsEphemeral {
- portal.ExpirationTime = groupInfo.DisappearingTimer
- }
- }
- if portal.IsNewsletter() {
- portal.UpdateNewsletterAvatar(ctx, user, newsletterMetadata)
- } else {
- portal.UpdateAvatar(ctx, user, types.EmptyJID, false)
- }
- }
-
- powerLevels := portal.GetBasePowerLevels()
-
- if groupInfo != nil {
- if groupInfo.IsAnnounce {
- powerLevels.EventsDefault = 50
- }
- if groupInfo.IsLocked {
- powerLevels.EnsureEventLevel(event.StateRoomName, 50)
- powerLevels.EnsureEventLevel(event.StateRoomAvatar, 50)
- powerLevels.EnsureEventLevel(event.StateTopic, 50)
- }
- }
- if newsletterMetadata != nil && newsletterMetadata.ViewerMeta != nil {
- switch newsletterMetadata.ViewerMeta.Role {
- case types.NewsletterRoleAdmin:
- powerLevels.EnsureUserLevel(user.MXID, 50)
- case types.NewsletterRoleOwner:
- powerLevels.EnsureUserLevel(user.MXID, 95)
- }
- }
-
- bridgeInfoStateKey, bridgeInfo := portal.getBridgeInfo()
-
- initialState := []*event.Event{{
- Type: event.StatePowerLevels,
- Content: event.Content{
- Parsed: powerLevels,
- },
- }, {
- Type: event.StateBridge,
- Content: event.Content{Parsed: bridgeInfo},
- StateKey: &bridgeInfoStateKey,
- }, {
- // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
- Type: event.StateHalfShotBridge,
- Content: event.Content{Parsed: bridgeInfo},
- StateKey: &bridgeInfoStateKey,
- }}
- var invite []id.UserID
- if portal.bridge.Config.Bridge.Encryption.Default {
- initialState = append(initialState, &event.Event{
- Type: event.StateEncryption,
- Content: event.Content{
- Parsed: portal.GetEncryptionEventContent(),
- },
- })
- portal.Encrypted = true
- if portal.IsPrivateChat() {
- invite = append(invite, portal.bridge.Bot.UserID)
- }
- }
- if !portal.AvatarURL.IsEmpty() && portal.shouldSetDMRoomMetadata() {
- initialState = append(initialState, &event.Event{
- Type: event.StateRoomAvatar,
- Content: event.Content{
- Parsed: event.RoomAvatarEventContent{URL: portal.AvatarURL.CUString()},
- },
- })
- portal.AvatarSet = true
- } else {
- portal.AvatarSet = false
- }
-
- creationContent := make(map[string]interface{})
- if !portal.bridge.Config.Bridge.FederateRooms {
- creationContent["m.federate"] = false
- }
- if portal.IsParent {
- creationContent["type"] = event.RoomTypeSpace
- } else if parent := portal.GetParentPortal(); parent != nil && parent.MXID != "" {
- initialState = append(initialState, &event.Event{
- Type: event.StateSpaceParent,
- StateKey: proto.String(parent.MXID.String()),
- Content: event.Content{
- Parsed: &event.SpaceParentEventContent{
- Via: []string{portal.bridge.Config.Homeserver.Domain},
- Canonical: true,
- },
- },
- })
- }
- autoJoinInvites := portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureAutojoinInvites)
- if autoJoinInvites {
- log.Debug().Msg("Hungryserv mode: adding all group members in create request")
- if groupInfo != nil && !portal.IsNewsletter() {
- // TODO non-hungryserv could also include all members in invites, and then send joins manually?
- participants, powerLevels := portal.SyncParticipants(ctx, user, groupInfo)
- invite = append(invite, participants...)
- if initialState[0].Type != event.StatePowerLevels {
- panic(fmt.Errorf("unexpected type %s in first initial state event", initialState[0].Type.Type))
- }
- initialState[0].Content.Parsed = powerLevels
- } else {
- invite = append(invite, user.MXID)
- }
- }
- req := &mautrix.ReqCreateRoom{
- Visibility: "private",
- Name: portal.Name,
- Topic: portal.Topic,
- Invite: invite,
- Preset: "private_chat",
- IsDirect: portal.IsPrivateChat(),
- InitialState: initialState,
- CreationContent: creationContent,
-
- BeeperAutoJoinInvites: autoJoinInvites,
- }
- if !portal.shouldSetDMRoomMetadata() {
- req.Name = ""
- }
- legacyBackfill := user.bridge.Config.Bridge.HistorySync.Backfill && backfill && !user.bridge.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending)
- var backfillStarted bool
- if legacyBackfill {
- portal.latestEventBackfillLock.Lock()
- defer func() {
- if !backfillStarted {
- portal.latestEventBackfillLock.Unlock()
- }
- }()
- }
- resp, err := intent.CreateRoom(ctx, req)
- if err != nil {
- return err
- }
- log.Info().Stringer("room_id", resp.RoomID).Msg("Matrix room created")
- portal.InSpace = false
- portal.NameSet = len(req.Name) > 0
- portal.TopicSet = len(req.Topic) > 0
- portal.MXID = resp.RoomID
- portal.updateLogger()
- portal.bridge.portalsLock.Lock()
- portal.bridge.portalsByMXID[portal.MXID] = portal
- portal.bridge.portalsLock.Unlock()
- err = portal.Update(ctx)
- if err != nil {
- log.Err(err).Msg("Failed to save portal after creating room")
- }
-
- // We set the memberships beforehand to make sure the encryption key exchange in initial backfill knows the users are here.
- inviteMembership := event.MembershipInvite
- if autoJoinInvites {
- inviteMembership = event.MembershipJoin
- }
- for _, userID := range invite {
- err = portal.bridge.StateStore.SetMembership(ctx, portal.MXID, userID, inviteMembership)
- if err != nil {
- log.Err(err).Stringer("user_id", userID).Msg("Failed to update membership in state store")
- }
- }
-
- if !autoJoinInvites {
- portal.ensureUserInvited(ctx, user)
- }
- user.syncChatDoublePuppetDetails(ctx, portal, true)
-
- go portal.updateCommunitySpace(ctx, user, true, true)
- go portal.addToPersonalSpace(ctx, user)
-
- if !portal.IsNewsletter() && groupInfo != nil && !autoJoinInvites {
- portal.SyncParticipants(ctx, user, groupInfo)
- }
- //if broadcastMetadata != nil {
- // portal.SyncBroadcastRecipients(user, broadcastMetadata)
- //}
- if portal.IsPrivateChat() {
- puppet := user.bridge.GetPuppetByJID(portal.Key.JID)
-
- if portal.bridge.Config.Bridge.Encryption.Default {
- err = portal.bridge.Bot.EnsureJoined(ctx, portal.MXID)
- if err != nil {
- log.Err(err).Msg("Failed to ensure bridge bot is joined to created portal")
- }
- }
-
- user.UpdateDirectChats(ctx, map[id.UserID][]id.RoomID{puppet.MXID: {portal.MXID}})
- } else if portal.IsParent {
- portal.updateChildRooms(ctx)
- }
-
- if user.bridge.Config.Bridge.HistorySync.Backfill && backfill {
- if legacyBackfill {
- backfillStarted = true
- go portal.legacyBackfill(context.WithoutCancel(ctx), user)
- } else {
- portals := []*Portal{portal}
- user.EnqueueImmediateBackfills(ctx, portals)
- user.EnqueueDeferredBackfills(ctx, portals)
- user.BackfillQueue.ReCheck()
- }
- }
- return nil
-}
-
-func (portal *Portal) addToPersonalSpace(ctx context.Context, user *User) {
- spaceID := user.GetSpaceRoom(ctx)
- if len(spaceID) == 0 || user.IsInSpace(ctx, portal.Key) {
- return
- }
- _, err := portal.bridge.Bot.SendStateEvent(ctx, spaceID, event.StateSpaceChild, portal.MXID.String(), &event.SpaceChildEventContent{
- Via: []string{portal.bridge.Config.Homeserver.Domain},
- })
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Stringer("space_id", spaceID).Msg("Failed to add portal to user's personal filtering space")
- } else {
- zerolog.Ctx(ctx).Debug().Stringer("space_id", spaceID).Msg("Added portal to user's personal filtering space")
- user.MarkInSpace(ctx, portal.Key)
- }
-}
-
-func (portal *Portal) removeSpaceParentEvent(space id.RoomID) {
- _, err := portal.MainIntent().SendStateEvent(context.TODO(), portal.MXID, event.StateSpaceParent, space.String(), &event.SpaceParentEventContent{})
- if err != nil {
- portal.zlog.Err(err).Stringer("space_mxid", space).Msg("Failed to send m.space.parent event to remove portal from space")
- }
-}
-
-func (portal *Portal) updateCommunitySpace(ctx context.Context, user *User, add, updateInfo bool) bool {
- if add == portal.InSpace {
- return false
- }
- // TODO if this function is changed to use the context logger, updateChildRooms should add the child portal info to the logger
- log := portal.zlog.With().Stringer("room_id", portal.MXID).Logger()
- space := portal.GetParentPortal()
- if space == nil {
- return false
- } else if space.MXID == "" {
- if !add || user == nil {
- return false
- }
- log.Debug().Stringer("parent_group_jid", space.Key.JID).Msg("Creating portal for parent group")
- err := space.CreateMatrixRoom(ctx, user, nil, nil, false, false)
- if err != nil {
- log.Err(err).Msg("Failed to create portal for parent group")
- return false
- }
- }
-
- var parentContent event.SpaceParentEventContent
- var childContent event.SpaceChildEventContent
- if add {
- parentContent.Canonical = true
- parentContent.Via = []string{portal.bridge.Config.Homeserver.Domain}
- childContent.Via = []string{portal.bridge.Config.Homeserver.Domain}
- log.Debug().
- Stringer("space_mxid", space.MXID).
- Stringer("parent_group_jid", space.Key.JID).
- Msg("Adding room to parent group space")
- } else {
- log.Debug().
- Stringer("space_mxid", space.MXID).
- Stringer("parent_group_jid", space.Key.JID).
- Msg("Removing room from parent group space")
- }
-
- _, err := space.MainIntent().SendStateEvent(ctx, space.MXID, event.StateSpaceChild, portal.MXID.String(), &childContent)
- if err != nil {
- log.Err(err).Stringer("space_mxid", space.MXID).Msg("Failed to send m.space.child event")
- return false
- }
- _, err = portal.MainIntent().SendStateEvent(ctx, portal.MXID, event.StateSpaceParent, space.MXID.String(), &parentContent)
- if err != nil {
- log.Err(err).Stringer("space_mxid", space.MXID).Msg("Failed to send m.space.parent event")
- }
- portal.InSpace = add
- if updateInfo {
- err = portal.Update(ctx)
- if err != nil {
- log.Err(err).Msg("Failed to save portal after updating parent space")
- }
- portal.UpdateBridgeInfo(ctx)
- }
- return true
-}
-
-func (portal *Portal) IsPrivateChat() bool {
- return portal.Key.JID.Server == types.DefaultUserServer
-}
-
-func (portal *Portal) IsGroupChat() bool {
- return portal.Key.JID.Server == types.GroupServer
-}
-
-func (portal *Portal) IsBroadcastList() bool {
- return portal.Key.JID.Server == types.BroadcastServer
-}
-
-func (portal *Portal) IsNewsletter() bool {
- return portal.Key.JID.Server == types.NewsletterServer
-}
-
-func (portal *Portal) IsStatusBroadcastList() bool {
- return portal.Key.JID == types.StatusBroadcastJID
-}
-
-func (portal *Portal) HasRelaybot() bool {
- return portal.bridge.Config.Bridge.Relay.Enabled && len(portal.RelayUserID) > 0
-}
-
-func (portal *Portal) GetRelayUser() *User {
- if !portal.HasRelaybot() {
- return nil
- } else if portal.relayUser == nil {
- portal.relayUser = portal.bridge.GetUserByMXID(portal.RelayUserID)
- }
- return portal.relayUser
-}
-
-func (portal *Portal) GetParentPortal() *Portal {
- if portal.ParentGroup.IsEmpty() {
- return nil
- } else if portal.parentPortal == nil {
- portal.parentPortal = portal.bridge.GetPortalByJID(database.NewPortalKey(portal.ParentGroup, portal.ParentGroup))
- }
- return portal.parentPortal
-}
-
-func (portal *Portal) MainIntent() *appservice.IntentAPI {
- if portal.IsPrivateChat() {
- return portal.bridge.GetPuppetByJID(portal.Key.JID).DefaultIntent()
- }
- return portal.bridge.Bot
-}
-
-func (portal *Portal) addReplyMention(content *event.MessageEventContent, sender types.JID, senderMXID id.UserID) {
- if content.Mentions == nil || (sender.IsEmpty() && senderMXID == "") {
- return
- }
- // TODO handle lids
- if senderMXID == "" && sender.Server == types.DefaultUserServer {
- if user := portal.bridge.GetUserByJID(sender); user != nil {
- senderMXID = user.MXID
- } else {
- puppet := portal.bridge.GetPuppetByJID(sender)
- senderMXID = puppet.MXID
- }
- }
- if senderMXID != "" && !slices.Contains(content.Mentions.UserIDs, senderMXID) {
- content.Mentions.UserIDs = append(content.Mentions.UserIDs, senderMXID)
- }
-}
-
-func (portal *Portal) SetReply(ctx context.Context, content *event.MessageEventContent, replyTo *ReplyInfo, isHungryBackfill bool) bool {
- if replyTo == nil {
- return false
- }
- log := zerolog.Ctx(ctx).With().
- Object("reply_to", replyTo).
- Str("action", "SetReply").
- Logger()
- key := portal.Key
- targetPortal := portal
- defer func() {
- if content.RelatesTo != nil && content.RelatesTo.InReplyTo != nil && targetPortal != portal {
- content.RelatesTo.InReplyTo.UnstableRoomID = targetPortal.MXID
- }
- }()
- if portal.bridge.Config.Bridge.CrossRoomReplies && !replyTo.Chat.IsEmpty() && replyTo.Chat != key.JID {
- if replyTo.Chat.Server == types.GroupServer {
- key = database.NewPortalKey(replyTo.Chat, types.EmptyJID)
- } else if replyTo.Chat == types.StatusBroadcastJID {
- key = database.NewPortalKey(replyTo.Chat, key.Receiver)
- }
- if key != portal.Key {
- targetPortal = portal.bridge.GetExistingPortalByJID(key)
- if targetPortal == nil {
- return false
- }
- }
- }
- message, err := portal.bridge.DB.Message.GetByJID(ctx, key, replyTo.MessageID)
- if err != nil {
- log.Err(err).Msg("Failed to get reply target from database")
- return false
- } else if message == nil || message.IsFakeMXID() {
- if isHungryBackfill {
- content.RelatesTo = (&event.RelatesTo{}).SetReplyTo(targetPortal.deterministicEventID(replyTo.Sender, replyTo.MessageID, ""))
- portal.addReplyMention(content, replyTo.Sender, "")
- return true
- } else {
- log.Warn().Msg("Failed to find reply target")
- }
- return false
- }
- portal.addReplyMention(content, message.Sender, message.SenderMXID)
- content.RelatesTo = (&event.RelatesTo{}).SetReplyTo(message.MXID)
- if portal.bridge.Config.Bridge.DisableReplyFallbacks {
- return true
- }
- evt, err := targetPortal.MainIntent().GetEvent(ctx, targetPortal.MXID, message.MXID)
- if err != nil {
- log.Warn().Err(err).Msg("Failed to get reply target event")
- return true
- }
- _ = evt.Content.ParseRaw(evt.Type)
- if evt.Type == event.EventEncrypted {
- decryptedEvt, err := portal.bridge.Crypto.Decrypt(ctx, evt)
- if err != nil {
- log.Warn().Err(err).Msg("Failed to decrypt reply target event")
- } else {
- evt = decryptedEvt
- }
- }
- content.SetReply(evt)
- return true
-}
-
-func (portal *Portal) HandleMessageReaction(ctx context.Context, intent *appservice.IntentAPI, user *User, info *types.MessageInfo, reaction *waProto.ReactionMessage, existingMsg *database.Message) {
- if existingMsg != nil {
- _, _ = portal.MainIntent().RedactEvent(ctx, portal.MXID, existingMsg.MXID, mautrix.ReqRedact{
- Reason: "The undecryptable message was actually a reaction",
- })
- }
-
- targetJID := reaction.GetKey().GetID()
- log := zerolog.Ctx(ctx).With().
- Str("reaction_target_id", targetJID).
- Logger()
- if reaction.GetText() == "" {
- existing, err := portal.bridge.DB.Reaction.GetByTargetJID(ctx, portal.Key, targetJID, info.Sender)
- if err != nil {
- log.Err(err).Msg("Failed to get existing reaction to remove")
- return
- } else if existing == nil {
- log.Debug().Msg("Dropping removal of unknown reaction")
- return
- }
-
- resp, err := intent.RedactEvent(ctx, portal.MXID, existing.MXID)
- if err != nil {
- log.Err(err).
- Stringer("reaction_mxid", existing.MXID).
- Msg("Failed to redact reaction")
- }
- portal.finishHandling(ctx, existingMsg, info, resp.EventID, intent.UserID, database.MsgReaction, 0, database.MsgNoError)
- err = existing.Delete(ctx)
- if err != nil {
- log.Err(err).Msg("Failed to delete reaction from database")
- }
- } else {
- target, err := portal.bridge.DB.Message.GetByJID(ctx, portal.Key, targetJID)
- if err != nil {
- log.Err(err).Msg("Failed to get reaction target message from database")
- return
- } else if target == nil {
- log.Debug().Msg("Dropping reaction to unknown message")
- return
- }
-
- var content event.ReactionEventContent
- content.RelatesTo = event.RelatesTo{
- Type: event.RelAnnotation,
- EventID: target.MXID,
- Key: variationselector.Add(reaction.GetText()),
- }
- resp, err := intent.SendMassagedMessageEvent(ctx, portal.MXID, event.EventReaction, &content, info.Timestamp.UnixMilli())
- if err != nil {
- log.Err(err).Msg("Failed to bridge reaction")
- return
- }
-
- portal.finishHandling(ctx, existingMsg, info, resp.EventID, intent.UserID, database.MsgReaction, 0, database.MsgNoError)
- portal.upsertReaction(ctx, intent, target.JID, info.Sender, resp.EventID, info.ID)
- }
-}
-
-func (portal *Portal) HandleMessageRevoke(ctx context.Context, user *User, info *types.MessageInfo, key *waProto.MessageKey) bool {
- log := zerolog.Ctx(ctx).With().Str("revoke_target_id", key.GetId()).Logger()
- msg, err := portal.bridge.DB.Message.GetByJID(ctx, portal.Key, key.GetId())
- if err != nil {
- log.Err(err).Msg("Failed to get revoke target message from database")
- return false
- } else if msg == nil || msg.IsFakeMXID() {
- return false
- }
- intent := portal.bridge.GetPuppetByJID(info.Sender).IntentFor(portal)
- _, err = intent.RedactEvent(ctx, portal.MXID, msg.MXID)
- if errors.Is(err, mautrix.MForbidden) {
- _, err = portal.MainIntent().RedactEvent(ctx, portal.MXID, msg.MXID)
- }
- if err != nil {
- log.Err(err).Stringer("revoke_target_mxid", msg.MXID).Msg("Failed to redact message from revoke")
- } else if err = msg.Delete(ctx); err != nil {
- log.Err(err).Msg("Failed to delete message from database after revoke")
- }
- return true
-}
-
-func (portal *Portal) deleteForMe(ctx context.Context, user *User, content *events.DeleteForMe) bool {
- matrixUsers, err := portal.GetMatrixUsers(ctx)
- if err != nil {
- portal.zlog.Err(err).Msg("Failed to get Matrix users in portal to see if DeleteForMe should be handled")
- return false
- }
- if len(matrixUsers) == 1 && matrixUsers[0] == user.MXID {
- msg, err := portal.bridge.DB.Message.GetByJID(ctx, portal.Key, content.MessageID)
- if msg == nil || msg.IsFakeMXID() {
- return false
- }
- _, err = portal.MainIntent().RedactEvent(ctx, portal.MXID, msg.MXID)
- if err != nil {
- portal.zlog.Err(err).Str("message_id", msg.JID).Msg("Failed to redact message from DeleteForMe")
- } else if err = msg.Delete(ctx); err != nil {
- portal.zlog.Err(err).Str("message_id", msg.JID).Msg("Failed to delete message from database after DeleteForMe")
- }
- return true
- }
- return false
-}
-
-func (portal *Portal) sendMainIntentMessage(ctx context.Context, content *event.MessageEventContent) (*mautrix.RespSendEvent, error) {
- return portal.sendMessage(ctx, portal.MainIntent(), event.EventMessage, content, nil, 0)
-}
-
-func (portal *Portal) encrypt(ctx context.Context, intent *appservice.IntentAPI, content *event.Content, eventType event.Type) (event.Type, error) {
- if !portal.Encrypted || portal.bridge.Crypto == nil {
- return eventType, nil
- }
- intent.AddDoublePuppetValue(content)
- // TODO maybe the locking should be inside mautrix-go?
- portal.encryptLock.Lock()
- defer portal.encryptLock.Unlock()
- err := portal.bridge.Crypto.Encrypt(ctx, portal.MXID, eventType, content)
- if err != nil {
- return eventType, fmt.Errorf("failed to encrypt event: %w", err)
- }
- return event.EventEncrypted, nil
-}
-
-func (portal *Portal) sendMessage(ctx context.Context, intent *appservice.IntentAPI, eventType event.Type, content *event.MessageEventContent, extraContent map[string]interface{}, timestamp int64) (*mautrix.RespSendEvent, error) {
- wrappedContent := event.Content{Parsed: content, Raw: extraContent}
- var err error
- eventType, err = portal.encrypt(ctx, intent, &wrappedContent, eventType)
- if err != nil {
- return nil, err
- }
-
- _, _ = intent.UserTyping(ctx, portal.MXID, false, 0)
- if timestamp == 0 {
- return intent.SendMessageEvent(ctx, portal.MXID, eventType, &wrappedContent)
- } else {
- return intent.SendMassagedMessageEvent(ctx, portal.MXID, eventType, &wrappedContent, timestamp)
- }
-}
-
-type ReplyInfo struct {
- MessageID types.MessageID
- Chat types.JID
- Sender types.JID
-}
-
-func (r *ReplyInfo) Equals(other *ReplyInfo) bool {
- if r == nil {
- return other == nil
- } else if other == nil {
- return false
- }
- return r.MessageID == other.MessageID && r.Chat == other.Chat && r.Sender == other.Sender
-}
-
-func (r ReplyInfo) MarshalZerologObject(e *zerolog.Event) {
- e.Str("message_id", r.MessageID)
- e.Str("chat_jid", r.Chat.String())
- e.Str("sender_jid", r.Sender.String())
-}
-
-type Replyable interface {
- GetStanzaID() string
- GetParticipant() string
- GetRemoteJid() string
-}
-
-func GetReply(replyable Replyable) *ReplyInfo {
- if replyable.GetStanzaID() == "" {
- return nil
- }
- sender, err := types.ParseJID(replyable.GetParticipant())
- if err != nil {
- return nil
- }
- chat, _ := types.ParseJID(replyable.GetRemoteJid())
- return &ReplyInfo{
- MessageID: types.MessageID(replyable.GetStanzaID()),
- Chat: chat,
- Sender: sender,
- }
-}
-
-type ConvertedMessage struct {
- Intent *appservice.IntentAPI
- Type event.Type
- Content *event.MessageEventContent
- Extra map[string]interface{}
- Caption *event.MessageEventContent
-
- MultiEvent []*event.MessageEventContent
-
- ReplyTo *ReplyInfo
- ExpiresIn time.Duration
- Error database.MessageErrorType
- MediaKey []byte
-}
-
-func (cm *ConvertedMessage) MergeCaption() {
- if cm.Caption == nil {
- return
- }
- cm.Content.FileName = cm.Content.Body
- extensibleCaption := map[string]interface{}{
- "org.matrix.msc1767.text": cm.Caption.Body,
- }
- cm.Extra["org.matrix.msc1767.caption"] = extensibleCaption
- cm.Content.Body = cm.Caption.Body
- if cm.Caption.Format == event.FormatHTML {
- cm.Content.Format = event.FormatHTML
- cm.Content.FormattedBody = cm.Caption.FormattedBody
- extensibleCaption["org.matrix.msc1767.html"] = cm.Caption.FormattedBody
- }
- cm.Caption = nil
-}
-func (portal *Portal) convertTextMessage(ctx context.Context, intent *appservice.IntentAPI, source *User, msg *waProto.Message) *ConvertedMessage {
- content := &event.MessageEventContent{
- Body: msg.GetConversation(),
- MsgType: event.MsgText,
- }
- if len(msg.GetExtendedTextMessage().GetText()) > 0 {
- content.Body = msg.GetExtendedTextMessage().GetText()
- }
-
- contextInfo := msg.GetExtendedTextMessage().GetContextInfo()
- portal.bridge.Formatter.ParseWhatsApp(ctx, portal.MXID, content, contextInfo.GetMentionedJID(), false, false)
- expiresIn := time.Duration(contextInfo.GetExpiration()) * time.Second
- extraAttrs := map[string]interface{}{}
- extraAttrs["com.beeper.linkpreviews"] = portal.convertURLPreviewToBeeper(ctx, intent, source, msg.GetExtendedTextMessage())
-
- return &ConvertedMessage{
- Intent: intent,
- Type: event.EventMessage,
- Content: content,
- ReplyTo: GetReply(contextInfo),
- ExpiresIn: expiresIn,
- Extra: extraAttrs,
- }
-}
-
-func (portal *Portal) convertTemplateMessage(ctx context.Context, intent *appservice.IntentAPI, source *User, info *types.MessageInfo, tplMsg *waProto.TemplateMessage) *ConvertedMessage {
- converted := &ConvertedMessage{
- Intent: intent,
- Type: event.EventMessage,
- Content: &event.MessageEventContent{
- Body: "Unsupported business message",
- MsgType: event.MsgText,
- },
- ReplyTo: GetReply(tplMsg.GetContextInfo()),
- ExpiresIn: time.Duration(tplMsg.GetContextInfo().GetExpiration()) * time.Second,
- }
-
- tpl := tplMsg.GetHydratedTemplate()
- if tpl == nil {
- return converted
- }
- content := tpl.GetHydratedContentText()
- if buttons := tpl.GetHydratedButtons(); len(buttons) > 0 {
- addButtonText := false
- descriptions := make([]string, len(buttons))
- for i, rawButton := range buttons {
- switch button := rawButton.GetHydratedButton().(type) {
- case *waProto.HydratedTemplateButton_QuickReplyButton:
- descriptions[i] = fmt.Sprintf("<%s>", button.QuickReplyButton.GetDisplayText())
- addButtonText = true
- case *waProto.HydratedTemplateButton_UrlButton:
- descriptions[i] = fmt.Sprintf("[%s](%s)", button.UrlButton.GetDisplayText(), button.UrlButton.GetURL())
- case *waProto.HydratedTemplateButton_CallButton:
- descriptions[i] = fmt.Sprintf("[%s](tel:%s)", button.CallButton.GetDisplayText(), button.CallButton.GetPhoneNumber())
- }
- }
- description := strings.Join(descriptions, " - ")
- if addButtonText {
- description += "\nUse the WhatsApp app to click buttons"
- }
- content = fmt.Sprintf("%s\n\n%s", content, description)
- }
- if footer := tpl.GetHydratedFooterText(); footer != "" {
- content = fmt.Sprintf("%s\n\n%s", content, footer)
- }
-
- var convertedTitle *ConvertedMessage
- switch title := tpl.GetTitle().(type) {
- case *waProto.TemplateMessage_HydratedFourRowTemplate_DocumentMessage:
- convertedTitle = portal.convertMediaMessage(ctx, intent, source, info, title.DocumentMessage, "file attachment", false)
- case *waProto.TemplateMessage_HydratedFourRowTemplate_ImageMessage:
- convertedTitle = portal.convertMediaMessage(ctx, intent, source, info, title.ImageMessage, "photo", false)
- case *waProto.TemplateMessage_HydratedFourRowTemplate_VideoMessage:
- convertedTitle = portal.convertMediaMessage(ctx, intent, source, info, title.VideoMessage, "video attachment", false)
- case *waProto.TemplateMessage_HydratedFourRowTemplate_LocationMessage:
- content = fmt.Sprintf("Unsupported location message\n\n%s", content)
- case *waProto.TemplateMessage_HydratedFourRowTemplate_HydratedTitleText:
- content = fmt.Sprintf("%s\n\n%s", title.HydratedTitleText, content)
- }
-
- converted.Content.Body = content
- portal.bridge.Formatter.ParseWhatsApp(ctx, portal.MXID, converted.Content, nil, true, false)
- if convertedTitle != nil {
- converted.MediaKey = convertedTitle.MediaKey
- converted.Extra = convertedTitle.Extra
- converted.Caption = converted.Content
- converted.Content = convertedTitle.Content
- converted.Error = convertedTitle.Error
- }
- if converted.Extra == nil {
- converted.Extra = make(map[string]interface{})
- }
- converted.Extra["fi.mau.whatsapp.hydrated_template_id"] = tpl.GetTemplateID()
- return converted
-}
-
-func (portal *Portal) convertTemplateButtonReplyMessage(ctx context.Context, intent *appservice.IntentAPI, msg *waProto.TemplateButtonReplyMessage) *ConvertedMessage {
- return &ConvertedMessage{
- Intent: intent,
- Type: event.EventMessage,
- Content: &event.MessageEventContent{
- Body: msg.GetSelectedDisplayText(),
- MsgType: event.MsgText,
- },
- Extra: map[string]interface{}{
- "fi.mau.whatsapp.template_button_reply": map[string]interface{}{
- "id": msg.GetSelectedID(),
- "index": msg.GetSelectedIndex(),
- },
- },
- ReplyTo: GetReply(msg.GetContextInfo()),
- ExpiresIn: time.Duration(msg.GetContextInfo().GetExpiration()) * time.Second,
- }
-}
-
-func (portal *Portal) convertListMessage(ctx context.Context, intent *appservice.IntentAPI, source *User, msg *waProto.ListMessage) *ConvertedMessage {
- converted := &ConvertedMessage{
- Intent: intent,
- Type: event.EventMessage,
- Content: &event.MessageEventContent{
- Body: "Unsupported business message",
- MsgType: event.MsgText,
- },
- ReplyTo: GetReply(msg.GetContextInfo()),
- ExpiresIn: time.Duration(msg.GetContextInfo().GetExpiration()) * time.Second,
- }
- body := msg.GetDescription()
- if msg.GetTitle() != "" {
- if body == "" {
- body = msg.GetTitle()
- } else {
- body = fmt.Sprintf("%s\n\n%s", msg.GetTitle(), body)
- }
- }
- randomID := random.String(64)
- body = fmt.Sprintf("%s\n%s", body, randomID)
- if msg.GetFooterText() != "" {
- body = fmt.Sprintf("%s\n\n%s", body, msg.GetFooterText())
- }
- converted.Content.Body = body
- portal.bridge.Formatter.ParseWhatsApp(ctx, portal.MXID, converted.Content, nil, false, true)
-
- var optionsMarkdown strings.Builder
- _, _ = fmt.Fprintf(&optionsMarkdown, "#### %s\n", msg.GetButtonText())
- for _, section := range msg.GetSections() {
- nesting := ""
- if section.GetTitle() != "" {
- _, _ = fmt.Fprintf(&optionsMarkdown, "* %s\n", section.GetTitle())
- nesting = " "
- }
- for _, row := range section.GetRows() {
- if row.GetDescription() != "" {
- _, _ = fmt.Fprintf(&optionsMarkdown, "%s* %s: %s\n", nesting, row.GetTitle(), row.GetDescription())
- } else {
- _, _ = fmt.Fprintf(&optionsMarkdown, "%s* %s\n", nesting, row.GetTitle())
- }
- }
- }
- optionsMarkdown.WriteString("\nUse the WhatsApp app to respond")
- rendered := format.RenderMarkdown(optionsMarkdown.String(), true, false)
- converted.Content.Body = strings.Replace(converted.Content.Body, randomID, rendered.Body, 1)
- converted.Content.FormattedBody = strings.Replace(converted.Content.FormattedBody, randomID, rendered.FormattedBody, 1)
- return converted
-}
-
-func (portal *Portal) convertListResponseMessage(ctx context.Context, intent *appservice.IntentAPI, msg *waProto.ListResponseMessage) *ConvertedMessage {
- var body string
- if msg.GetTitle() != "" {
- if msg.GetDescription() != "" {
- body = fmt.Sprintf("%s\n\n%s", msg.GetTitle(), msg.GetDescription())
- } else {
- body = msg.GetTitle()
- }
- } else if msg.GetDescription() != "" {
- body = msg.GetDescription()
- } else {
- body = "Unsupported list reply message"
- }
- return &ConvertedMessage{
- Intent: intent,
- Type: event.EventMessage,
- Content: &event.MessageEventContent{
- Body: body,
- MsgType: event.MsgText,
- },
- Extra: map[string]interface{}{
- "fi.mau.whatsapp.list_reply": map[string]interface{}{
- "row_id": msg.GetSingleSelectReply().GetSelectedRowID(),
- },
- },
- ReplyTo: GetReply(msg.GetContextInfo()),
- ExpiresIn: time.Duration(msg.GetContextInfo().GetExpiration()) * time.Second,
- }
-}
-
-func (portal *Portal) convertPollUpdateMessage(ctx context.Context, intent *appservice.IntentAPI, source *User, info *types.MessageInfo, msg *waProto.PollUpdateMessage) *ConvertedMessage {
- log := zerolog.Ctx(ctx).With().
- Str("poll_id", msg.GetPollCreationMessageKey().GetId()).
- Logger()
- pollMessage, err := portal.bridge.DB.Message.GetByJID(ctx, portal.Key, msg.GetPollCreationMessageKey().GetId())
- if err != nil {
- log.Err(err).Msg("Failed to get poll message to convert vote")
- return nil
- } else if pollMessage == nil {
- log.Warn().Msg("Poll message not found for converting vote message")
- return nil
- }
- vote, err := source.Client.DecryptPollVote(&events.Message{
- Info: *info,
- Message: &waProto.Message{PollUpdateMessage: msg},
- })
- if err != nil {
- log.Err(err).Msg("Failed to decrypt vote message")
- return nil
- }
- selectedHashes := make([]string, len(vote.GetSelectedOptions()))
- if pollMessage.Type == database.MsgMatrixPoll {
- mappedAnswers, err := pollMessage.GetPollOptionIDs(ctx, vote.GetSelectedOptions())
- if err != nil {
- log.Err(err).Msg("Failed to get poll option IDs")
- return nil
- }
- for i, opt := range vote.GetSelectedOptions() {
- if len(opt) != 32 {
- log.Warn().Int("hash_len", len(opt)).Msg("Unexpected option hash length in vote")
- continue
- }
- var ok bool
- selectedHashes[i], ok = mappedAnswers[[32]byte(opt)]
- if !ok {
- log.Warn().Hex("option_hash", opt).Msg("Didn't find ID for option in vote")
- }
- }
- } else {
- for i, opt := range vote.GetSelectedOptions() {
- selectedHashes[i] = hex.EncodeToString(opt)
- }
- }
-
- evtType := TypeMSC3381PollResponse
- //if portal.bridge.Config.Bridge.ExtEvPolls == 2 {
- // evtType = TypeMSC3381V2PollResponse
- //}
- return &ConvertedMessage{
- Intent: intent,
- Type: evtType,
- Content: &event.MessageEventContent{
- RelatesTo: &event.RelatesTo{
- Type: event.RelReference,
- EventID: pollMessage.MXID,
- },
- },
- Extra: map[string]any{
- "org.matrix.msc3381.poll.response": map[string]any{
- "answers": selectedHashes,
- },
- //"org.matrix.msc3381.v2.selections": selectedHashes,
- },
- }
-}
-
-func (portal *Portal) convertPollCreationMessage(ctx context.Context, intent *appservice.IntentAPI, msg *waProto.PollCreationMessage) *ConvertedMessage {
- optionNames := make([]string, len(msg.GetOptions()))
- optionsListText := make([]string, len(optionNames))
- optionsListHTML := make([]string, len(optionNames))
- msc3381Answers := make([]map[string]any, len(optionNames))
- msc3381V2Answers := make([]map[string]any, len(optionNames))
- for i, opt := range msg.GetOptions() {
- optionNames[i] = opt.GetOptionName()
- optionsListText[i] = fmt.Sprintf("%d. %s\n", i+1, optionNames[i])
- optionsListHTML[i] = fmt.Sprintf("%s", event.TextToHTML(optionNames[i]))
- optionHash := sha256.Sum256([]byte(opt.GetOptionName()))
- optionHashStr := hex.EncodeToString(optionHash[:])
- msc3381Answers[i] = map[string]any{
- "id": optionHashStr,
- "org.matrix.msc1767.text": opt.GetOptionName(),
- }
- msc3381V2Answers[i] = map[string]any{
- "org.matrix.msc3381.v2.id": optionHashStr,
- "org.matrix.msc1767.markup": []map[string]any{
- {"mimetype": "text/plain", "body": opt.GetOptionName()},
- },
- }
- }
- body := fmt.Sprintf("%s\n\n%s\n\n(This message is a poll. Please open WhatsApp to vote.)", msg.GetName(), strings.Join(optionsListText, "\n"))
- formattedBody := fmt.Sprintf("%s
%s
(This message is a poll. Please open WhatsApp to vote.)
", event.TextToHTML(msg.GetName()), strings.Join(optionsListHTML, ""))
- maxChoices := int(msg.GetSelectableOptionsCount())
- if maxChoices <= 0 {
- maxChoices = len(optionNames)
- }
- evtType := event.EventMessage
- if portal.bridge.Config.Bridge.ExtEvPolls {
- evtType = TypeMSC3381PollStart
- }
- //else if portal.bridge.Config.Bridge.ExtEvPolls == 2 {
- // evtType.Type = "org.matrix.msc3381.v2.poll.start"
- //}
- return &ConvertedMessage{
- Intent: intent,
- Type: evtType,
- Content: &event.MessageEventContent{
- Body: body,
- MsgType: event.MsgText,
- Format: event.FormatHTML,
- FormattedBody: formattedBody,
- },
- Extra: map[string]any{
- // Custom metadata
- "fi.mau.whatsapp.poll": map[string]any{
- "option_names": optionNames,
- "selectable_options_count": msg.GetSelectableOptionsCount(),
- },
-
- // Slightly less extensible events (November 2022)
- //"org.matrix.msc1767.markup": []map[string]any{
- // {"mimetype": "text/html", "body": formattedBody},
- // {"mimetype": "text/plain", "body": body},
- //},
- //"org.matrix.msc3381.v2.poll": map[string]any{
- // "kind": "org.matrix.msc3381.v2.disclosed",
- // "max_selections": maxChoices,
- // "question": map[string]any{
- // "org.matrix.msc1767.markup": []map[string]any{
- // {"mimetype": "text/plain", "body": msg.GetName()},
- // },
- // },
- // "answers": msc3381V2Answers,
- //},
-
- // Legacyest extensible events
- "org.matrix.msc1767.message": []map[string]any{
- {"mimetype": "text/html", "body": formattedBody},
- {"mimetype": "text/plain", "body": body},
- },
- "org.matrix.msc3381.poll.start": map[string]any{
- "kind": "org.matrix.msc3381.poll.disclosed",
- "max_selections": maxChoices,
- "question": map[string]any{
- "org.matrix.msc1767.text": msg.GetName(),
- },
- "answers": msc3381Answers,
- },
- },
- ReplyTo: GetReply(msg.GetContextInfo()),
- ExpiresIn: time.Duration(msg.GetContextInfo().GetExpiration()) * time.Second,
- }
-}
-
-func (portal *Portal) convertLiveLocationMessage(ctx context.Context, intent *appservice.IntentAPI, msg *waProto.LiveLocationMessage) *ConvertedMessage {
- content := &event.MessageEventContent{
- Body: "Started sharing live location",
- MsgType: event.MsgNotice,
- }
- if len(msg.GetCaption()) > 0 {
- content.Body += ": " + msg.GetCaption()
- }
- content.Body += "\n\nUse the WhatsApp app to see the location."
- return &ConvertedMessage{
- Intent: intent,
- Type: event.EventMessage,
- Content: content,
- ReplyTo: GetReply(msg.GetContextInfo()),
- ExpiresIn: time.Duration(msg.GetContextInfo().GetExpiration()) * time.Second,
- }
-}
-
-func (portal *Portal) convertLocationMessage(ctx context.Context, intent *appservice.IntentAPI, msg *waProto.LocationMessage) *ConvertedMessage {
- url := msg.GetURL()
- if len(url) == 0 {
- url = fmt.Sprintf("https://maps.google.com/?q=%.5f,%.5f", msg.GetDegreesLatitude(), msg.GetDegreesLongitude())
- }
- name := msg.GetName()
- if len(name) == 0 {
- latChar := 'N'
- if msg.GetDegreesLatitude() < 0 {
- latChar = 'S'
- }
- longChar := 'E'
- if msg.GetDegreesLongitude() < 0 {
- longChar = 'W'
- }
- name = fmt.Sprintf("%.4f° %c %.4f° %c", math.Abs(msg.GetDegreesLatitude()), latChar, math.Abs(msg.GetDegreesLongitude()), longChar)
- }
-
- content := &event.MessageEventContent{
- MsgType: event.MsgLocation,
- Body: fmt.Sprintf("Location: %s\n%s\n%s", name, msg.GetAddress(), url),
- Format: event.FormatHTML,
- FormattedBody: fmt.Sprintf("Location: %s
%s", url, name, msg.GetAddress()),
- GeoURI: fmt.Sprintf("geo:%.5f,%.5f", msg.GetDegreesLatitude(), msg.GetDegreesLongitude()),
- }
-
- if len(msg.GetJPEGThumbnail()) > 0 {
- thumbnailMime := http.DetectContentType(msg.GetJPEGThumbnail())
- uploadedThumbnail, _ := intent.UploadBytes(ctx, msg.GetJPEGThumbnail(), thumbnailMime)
- if uploadedThumbnail != nil {
- cfg, _, _ := image.DecodeConfig(bytes.NewReader(msg.GetJPEGThumbnail()))
- content.Info = &event.FileInfo{
- ThumbnailInfo: &event.FileInfo{
- Size: len(msg.GetJPEGThumbnail()),
- Width: cfg.Width,
- Height: cfg.Height,
- MimeType: thumbnailMime,
- },
- ThumbnailURL: uploadedThumbnail.ContentURI.CUString(),
- }
- }
- }
-
- return &ConvertedMessage{
- Intent: intent,
- Type: event.EventMessage,
- Content: content,
- ReplyTo: GetReply(msg.GetContextInfo()),
- ExpiresIn: time.Duration(msg.GetContextInfo().GetExpiration()) * time.Second,
- }
-}
-
-const inviteMsg = `%s
This invitation to join "%s" expires at %s. Reply to this message with !wa accept
to accept the invite.`
-const inviteMsgBroken = `%s
This invitation to join "%s" expires at %s. However, the invite message is broken or unsupported and cannot be accepted.`
-const inviteMetaField = "fi.mau.whatsapp.invite"
-const escapedInviteMetaField = `fi\.mau\.whatsapp\.invite`
-
-type InviteMeta struct {
- JID types.JID `json:"jid"`
- Code string `json:"code"`
- Expiration int64 `json:"expiration,string"`
- Inviter types.JID `json:"inviter"`
-}
-
-func (portal *Portal) convertGroupInviteMessage(ctx context.Context, intent *appservice.IntentAPI, info *types.MessageInfo, msg *waProto.GroupInviteMessage) *ConvertedMessage {
- expiry := time.Unix(msg.GetInviteExpiration(), 0)
- template := inviteMsg
- var extraAttrs map[string]any
- groupJID, err := types.ParseJID(msg.GetGroupJID())
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Str("invite_group_jid", msg.GetGroupJID()).Msg("Failed to parse invite group JID")
- template = inviteMsgBroken
- } else {
- extraAttrs = map[string]interface{}{
- inviteMetaField: InviteMeta{
- JID: groupJID,
- Code: msg.GetInviteCode(),
- Expiration: msg.GetInviteExpiration(),
- Inviter: info.Sender.ToNonAD(),
- },
- }
- }
-
- htmlMessage := fmt.Sprintf(template, event.TextToHTML(msg.GetCaption()), msg.GetGroupName(), expiry)
- content := &event.MessageEventContent{
- MsgType: event.MsgText,
- Body: format.HTMLToText(htmlMessage),
- Format: event.FormatHTML,
- FormattedBody: htmlMessage,
- }
- return &ConvertedMessage{
- Intent: intent,
- Type: event.EventMessage,
- Content: content,
- Extra: extraAttrs,
- ReplyTo: GetReply(msg.GetContextInfo()),
- ExpiresIn: time.Duration(msg.GetContextInfo().GetExpiration()) * time.Second,
- }
-}
-
-func (portal *Portal) convertContactMessage(ctx context.Context, intent *appservice.IntentAPI, msg *waProto.ContactMessage) *ConvertedMessage {
- fileName := fmt.Sprintf("%s.vcf", msg.GetDisplayName())
- data := []byte(msg.GetVcard())
- mimeType := "text/vcard"
- uploadMimeType, file := portal.encryptFileInPlace(data, mimeType)
-
- uploadResp, err := intent.UploadBytesWithName(ctx, data, uploadMimeType, fileName)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Str("displayname", msg.GetDisplayName()).Msg("Failed to upload vcard")
- return nil
- }
-
- content := &event.MessageEventContent{
- Body: fileName,
- MsgType: event.MsgFile,
- File: file,
- Info: &event.FileInfo{
- MimeType: mimeType,
- Size: len(msg.GetVcard()),
- },
- }
- if content.File != nil {
- content.File.URL = uploadResp.ContentURI.CUString()
- } else {
- content.URL = uploadResp.ContentURI.CUString()
- }
-
- return &ConvertedMessage{
- Intent: intent,
- Type: event.EventMessage,
- Content: content,
- ReplyTo: GetReply(msg.GetContextInfo()),
- ExpiresIn: time.Duration(msg.GetContextInfo().GetExpiration()) * time.Second,
- }
-}
-
-func (portal *Portal) convertContactsArrayMessage(ctx context.Context, intent *appservice.IntentAPI, msg *waProto.ContactsArrayMessage) *ConvertedMessage {
- name := msg.GetDisplayName()
- if len(name) == 0 {
- name = fmt.Sprintf("%d contacts", len(msg.GetContacts()))
- }
- contacts := make([]*event.MessageEventContent, 0, len(msg.GetContacts()))
- for _, contact := range msg.GetContacts() {
- converted := portal.convertContactMessage(ctx, intent, contact)
- if converted != nil {
- contacts = append(contacts, converted.Content)
- }
- }
- return &ConvertedMessage{
- Intent: intent,
- Type: event.EventMessage,
- Content: &event.MessageEventContent{
- MsgType: event.MsgNotice,
- Body: fmt.Sprintf("Sent %s", name),
- },
- ReplyTo: GetReply(msg.GetContextInfo()),
- ExpiresIn: time.Duration(msg.GetContextInfo().GetExpiration()) * time.Second,
- MultiEvent: contacts,
- }
-}
-
-func (portal *Portal) tryKickUser(ctx context.Context, userID id.UserID, intent *appservice.IntentAPI) error {
- _, err := intent.KickUser(ctx, portal.MXID, &mautrix.ReqKickUser{UserID: userID})
- if errors.Is(err, mautrix.MForbidden) {
- _, err = portal.MainIntent().KickUser(ctx, portal.MXID, &mautrix.ReqKickUser{UserID: userID})
- }
- return err
-}
-
-func (portal *Portal) removeUser(ctx context.Context, isSameUser bool, kicker *appservice.IntentAPI, target id.UserID, targetIntent *appservice.IntentAPI) {
- if !isSameUser || targetIntent == nil {
- err := portal.tryKickUser(ctx, target, kicker)
- if err != nil {
- zerolog.Ctx(ctx).Warn().Err(err).Stringer("target_mxid", target).Msg("Failed to kick user from portal")
- if targetIntent != nil {
- _, _ = targetIntent.LeaveRoom(ctx, portal.MXID)
- }
- }
- } else {
- _, err := targetIntent.LeaveRoom(ctx, portal.MXID)
- if err != nil {
- zerolog.Ctx(ctx).Warn().Err(err).Stringer("target_mxid", target).Msg("Failed to leave portal as user")
- _, _ = portal.MainIntent().KickUser(ctx, portal.MXID, &mautrix.ReqKickUser{UserID: target})
- }
- }
- portal.CleanupIfEmpty(ctx)
-}
-
-func (portal *Portal) HandleWhatsAppKick(ctx context.Context, source *User, senderJID types.JID, jids []types.JID) {
- sender := portal.bridge.GetPuppetByJID(senderJID)
- senderIntent := sender.IntentFor(portal)
- for _, jid := range jids {
- if jid.Server != types.DefaultUserServer {
- // TODO handle lids
- continue
- }
- //if source != nil && source.JID.User == jid.User {
- // portal.log.Debugln("Ignoring self-kick by", source.MXID)
- // continue
- //}
- puppet := portal.bridge.GetPuppetByJID(jid)
- portal.removeUser(ctx, puppet.JID == sender.JID, senderIntent, puppet.MXID, puppet.DefaultIntent())
-
- if !portal.IsBroadcastList() {
- user := portal.bridge.GetUserByJID(jid)
- if user != nil {
- var customIntent *appservice.IntentAPI
- if puppet.CustomMXID == user.MXID {
- customIntent = puppet.CustomIntent()
- }
- portal.removeUser(ctx, puppet.JID == sender.JID, senderIntent, user.MXID, customIntent)
- }
- }
- }
-}
-
-func (portal *Portal) HandleWhatsAppInvite(ctx context.Context, source *User, senderJID *types.JID, jids []types.JID) (evtID id.EventID) {
- intent := portal.MainIntent()
- if senderJID != nil && !senderJID.IsEmpty() {
- sender := portal.bridge.GetPuppetByJID(*senderJID)
- intent = sender.IntentFor(portal)
- }
- for _, jid := range jids {
- if jid.Server != types.DefaultUserServer {
- // TODO handle lids
- continue
- }
- puppet := portal.bridge.GetPuppetByJID(jid)
- puppet.SyncContact(ctx, source, true, false, "handling whatsapp invite")
- resp, err := intent.SendStateEvent(ctx, portal.MXID, event.StateMember, puppet.MXID.String(), &event.MemberEventContent{
- Membership: event.MembershipInvite,
- Displayname: puppet.Displayname,
- AvatarURL: puppet.AvatarURL.CUString(),
- })
- if err != nil {
- zerolog.Ctx(ctx).Warn().Err(err).
- Stringer("target_mxid", puppet.MXID).
- Stringer("inviter_mxid", intent.UserID).
- Msg("Failed to invite user")
- _ = portal.MainIntent().EnsureInvited(ctx, portal.MXID, puppet.MXID)
- } else {
- evtID = resp.EventID
- }
- err = puppet.DefaultIntent().EnsureJoined(ctx, portal.MXID)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).
- Stringer("target_mxid", puppet.MXID).
- Msg("Failed to ensure user is joined to portal")
- }
- }
- return
-}
-
-func (portal *Portal) HandleWhatsAppDeleteChat(ctx context.Context, user *User) {
- if portal.MXID == "" {
- return
- }
- matrixUsers, err := portal.GetMatrixUsers(ctx)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to get Matrix users to see if DeleteChat should be handled")
- return
- }
- if len(matrixUsers) > 1 {
- zerolog.Ctx(ctx).Debug().Msg("Portal contains more than one Matrix user, ignoring DeleteChat event")
- return
- } else if (len(matrixUsers) == 1 && matrixUsers[0] == user.MXID) || len(matrixUsers) < 1 {
- zerolog.Ctx(ctx).Debug().Msg("User deleted chat and there are no other Matrix users, deleting portal...")
- portal.Delete(ctx)
- portal.Cleanup(ctx, false)
- }
-}
-
-const failedMediaField = "fi.mau.whatsapp.failed_media"
-
-type FailedMediaKeys struct {
- Key []byte `json:"key"`
- Length int `json:"length"`
- Type whatsmeow.MediaType `json:"type"`
- SHA256 []byte `json:"sha256"`
- EncSHA256 []byte `json:"enc_sha256"`
-}
-
-type FailedMediaMeta struct {
- Type event.Type `json:"type"`
- Content *event.MessageEventContent `json:"content"`
- ExtraContent map[string]interface{} `json:"extra_content,omitempty"`
- Media FailedMediaKeys `json:"whatsapp_media"`
-}
-
-func (portal *Portal) makeMediaBridgeFailureMessage(info *types.MessageInfo, bridgeErr error, converted *ConvertedMessage, keys *FailedMediaKeys, userFriendlyError string) *ConvertedMessage {
- if errors.Is(bridgeErr, whatsmeow.ErrMediaDownloadFailedWith403) || errors.Is(bridgeErr, whatsmeow.ErrMediaDownloadFailedWith404) || errors.Is(bridgeErr, whatsmeow.ErrMediaDownloadFailedWith410) {
- portal.zlog.Debug().Err(bridgeErr).Str("message_id", info.ID).Msg("Failed to bridge media for message")
- } else {
- portal.zlog.Err(bridgeErr).Str("message_id", info.ID).Msg("Failed to bridge media for message")
- }
- if keys != nil {
- if portal.bridge.Config.Bridge.CaptionInMessage {
- converted.MergeCaption()
- }
- meta := &FailedMediaMeta{
- Type: converted.Type,
- Content: converted.Content,
- ExtraContent: maps.Clone(converted.Extra),
- Media: *keys,
- }
- converted.Extra[failedMediaField] = meta
- portal.mediaErrorCache[info.ID] = meta
- }
- converted.Type = event.EventMessage
- body := userFriendlyError
- if body == "" {
- body = fmt.Sprintf("Failed to bridge media: %v", bridgeErr)
- }
- converted.Content = &event.MessageEventContent{
- MsgType: event.MsgNotice,
- Body: body,
- }
- return converted
-}
-
-func (portal *Portal) encryptFileInPlace(data []byte, mimeType string) (string, *event.EncryptedFileInfo) {
- if !portal.Encrypted {
- return mimeType, nil
- }
-
- file := &event.EncryptedFileInfo{
- EncryptedFile: *attachment.NewEncryptedFile(),
- URL: "",
- }
- file.EncryptInPlace(data)
- return "application/octet-stream", file
-}
-
-type MediaMessage interface {
- whatsmeow.DownloadableMessage
- GetContextInfo() *waProto.ContextInfo
- GetFileLength() uint64
- GetMimetype() string
-}
-
-type MediaMessageWithThumbnail interface {
- MediaMessage
- GetJPEGThumbnail() []byte
-}
-
-type MediaMessageWithCaption interface {
- MediaMessage
- GetCaption() string
-}
-
-type MediaMessageWithDimensions interface {
- MediaMessage
- GetHeight() uint32
- GetWidth() uint32
-}
-
-type MediaMessageWithFileName interface {
- MediaMessage
- GetFileName() string
-}
-
-type MediaMessageWithDuration interface {
- MediaMessage
- GetSeconds() uint32
-}
-
-const WhatsAppStickerSize = 190
-
-func (portal *Portal) convertMediaMessageContent(ctx context.Context, intent *appservice.IntentAPI, msg MediaMessage) *ConvertedMessage {
- content := &event.MessageEventContent{
- Info: &event.FileInfo{
- MimeType: msg.GetMimetype(),
- Size: int(msg.GetFileLength()),
- },
- }
- extraContent := map[string]interface{}{}
-
- messageWithDimensions, ok := msg.(MediaMessageWithDimensions)
- if ok {
- content.Info.Width = int(messageWithDimensions.GetWidth())
- content.Info.Height = int(messageWithDimensions.GetHeight())
- }
-
- msgWithName, ok := msg.(MediaMessageWithFileName)
- if ok && len(msgWithName.GetFileName()) > 0 {
- content.Body = msgWithName.GetFileName()
- } else {
- mimeClass := strings.Split(msg.GetMimetype(), "/")[0]
- switch mimeClass {
- case "application":
- content.Body = "file"
- default:
- content.Body = mimeClass
- }
-
- content.Body += exmime.ExtensionFromMimetype(msg.GetMimetype())
- }
-
- msgWithDuration, ok := msg.(MediaMessageWithDuration)
- if ok {
- content.Info.Duration = int(msgWithDuration.GetSeconds()) * 1000
- }
-
- videoMessage, ok := msg.(*waProto.VideoMessage)
- var isGIF bool
- if ok && videoMessage.GetGifPlayback() {
- isGIF = true
- extraContent["info"] = map[string]interface{}{
- "fi.mau.loop": true,
- "fi.mau.autoplay": true,
- "fi.mau.hide_controls": true,
- "fi.mau.no_audio": true,
- "fi.mau.gif": true,
- }
- }
-
- messageWithThumbnail, ok := msg.(MediaMessageWithThumbnail)
- if ok && messageWithThumbnail.GetJPEGThumbnail() != nil && (portal.bridge.Config.Bridge.WhatsappThumbnail || isGIF) {
- thumbnailData := messageWithThumbnail.GetJPEGThumbnail()
- thumbnailMime := http.DetectContentType(thumbnailData)
- thumbnailCfg, _, _ := image.DecodeConfig(bytes.NewReader(thumbnailData))
- thumbnailSize := len(thumbnailData)
- thumbnailUploadMime, thumbnailFile := portal.encryptFileInPlace(thumbnailData, thumbnailMime)
- uploadedThumbnail, err := intent.UploadBytes(ctx, thumbnailData, thumbnailUploadMime)
- if err != nil {
- zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to upload thumbnail")
- } else if uploadedThumbnail != nil {
- if thumbnailFile != nil {
- thumbnailFile.URL = uploadedThumbnail.ContentURI.CUString()
- content.Info.ThumbnailFile = thumbnailFile
- } else {
- content.Info.ThumbnailURL = uploadedThumbnail.ContentURI.CUString()
- }
- content.Info.ThumbnailInfo = &event.FileInfo{
- Size: thumbnailSize,
- Width: thumbnailCfg.Width,
- Height: thumbnailCfg.Height,
- MimeType: thumbnailMime,
- }
- }
- }
-
- eventType := event.EventMessage
- switch msg.(type) {
- case *waProto.ImageMessage:
- content.MsgType = event.MsgImage
- case *waProto.StickerMessage:
- eventType = event.EventSticker
- if content.Info.Width > content.Info.Height {
- content.Info.Height /= content.Info.Width / WhatsAppStickerSize
- content.Info.Width = WhatsAppStickerSize
- } else if content.Info.Width < content.Info.Height {
- content.Info.Width /= content.Info.Height / WhatsAppStickerSize
- content.Info.Height = WhatsAppStickerSize
- } else {
- content.Info.Width = WhatsAppStickerSize
- content.Info.Height = WhatsAppStickerSize
- }
- case *waProto.VideoMessage:
- content.MsgType = event.MsgVideo
- case *waProto.AudioMessage:
- content.MsgType = event.MsgAudio
- case *waProto.DocumentMessage:
- content.MsgType = event.MsgFile
- default:
- zerolog.Ctx(ctx).Warn().Type("content_struct", msg).Msg("Unexpected media type in convertMediaMessageContent")
- content.MsgType = event.MsgFile
- }
-
- audioMessage, ok := msg.(*waProto.AudioMessage)
- if ok {
- var waveform []int
- if audioMessage.Waveform != nil {
- waveform = make([]int, len(audioMessage.Waveform))
- maxWave := 0
- for i, part := range audioMessage.Waveform {
- waveform[i] = int(part)
- if waveform[i] > maxWave {
- maxWave = waveform[i]
- }
- }
- multiplier := 0
- if maxWave > 0 {
- multiplier = 1024 / maxWave
- }
- if multiplier > 32 {
- multiplier = 32
- }
- for i := range waveform {
- waveform[i] *= multiplier
- }
- }
- extraContent["org.matrix.msc1767.audio"] = map[string]interface{}{
- "duration": int(audioMessage.GetSeconds()) * 1000,
- "waveform": waveform,
- }
- if audioMessage.GetPTT() || audioMessage.GetMimetype() == "audio/ogg; codecs/opus" {
- extraContent["org.matrix.msc3245.voice"] = map[string]interface{}{}
- }
- }
-
- messageWithCaption, ok := msg.(MediaMessageWithCaption)
- var captionContent *event.MessageEventContent
- if ok && len(messageWithCaption.GetCaption()) > 0 {
- captionContent = &event.MessageEventContent{
- Body: messageWithCaption.GetCaption(),
- MsgType: event.MsgNotice,
- }
-
- portal.bridge.Formatter.ParseWhatsApp(ctx, portal.MXID, captionContent, msg.GetContextInfo().GetMentionedJID(), false, false)
- }
-
- return &ConvertedMessage{
- Intent: intent,
- Type: eventType,
- Content: content,
- Caption: captionContent,
- ReplyTo: GetReply(msg.GetContextInfo()),
- ExpiresIn: time.Duration(msg.GetContextInfo().GetExpiration()) * time.Second,
- Extra: extraContent,
- }
-}
-
-func (portal *Portal) uploadMedia(ctx context.Context, intent *appservice.IntentAPI, data []byte, content *event.MessageEventContent) error {
- uploadMimeType, file := portal.encryptFileInPlace(data, content.Info.MimeType)
-
- req := mautrix.ReqUploadMedia{
- ContentBytes: data,
- ContentType: uploadMimeType,
- }
- var mxc id.ContentURI
- if portal.bridge.Config.Homeserver.AsyncMedia {
- uploaded, err := intent.UploadAsync(ctx, req)
- if err != nil {
- return err
- }
- mxc = uploaded.ContentURI
- } else {
- uploaded, err := intent.UploadMedia(ctx, req)
- if err != nil {
- return err
- }
- mxc = uploaded.ContentURI
- }
-
- if file != nil {
- file.URL = mxc.CUString()
- content.File = file
- } else {
- content.URL = mxc.CUString()
- }
-
- content.Info.Size = len(data)
- if content.Info.Width == 0 && content.Info.Height == 0 && strings.HasPrefix(content.Info.MimeType, "image/") {
- cfg, _, _ := image.DecodeConfig(bytes.NewReader(data))
- content.Info.Width, content.Info.Height = cfg.Width, cfg.Height
- }
-
- // This is a hack for bad clients like Element iOS that require a thumbnail (https://github.com/vector-im/element-ios/issues/4004)
- if strings.HasPrefix(content.Info.MimeType, "image/") && content.Info.ThumbnailInfo == nil {
- infoCopy := *content.Info
- content.Info.ThumbnailInfo = &infoCopy
- if content.File != nil {
- content.Info.ThumbnailFile = file
- } else {
- content.Info.ThumbnailURL = content.URL
- }
- }
- return nil
-}
-
-func (portal *Portal) convertMediaMessage(ctx context.Context, intent *appservice.IntentAPI, source *User, info *types.MessageInfo, msg MediaMessage, typeName string, isBackfill bool) *ConvertedMessage {
- converted := portal.convertMediaMessageContent(ctx, intent, msg)
- if msg.GetFileLength() > uint64(portal.bridge.MediaConfig.UploadSize) {
- return portal.makeMediaBridgeFailureMessage(info, errors.New("file is too large"), converted, nil, fmt.Sprintf("Large %s not bridged - please use WhatsApp app to view", typeName))
- }
- data, err := source.Client.Download(msg)
- if errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith403) || errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith404) || errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith410) {
- converted.Error = database.MsgErrMediaNotFound
- converted.MediaKey = msg.GetMediaKey()
-
- errorText := fmt.Sprintf("Old %s.", typeName)
- if portal.bridge.Config.Bridge.HistorySync.MediaRequests.AutoRequestMedia && isBackfill {
- errorText += " Media will be automatically requested from your phone later."
- } else {
- errorText += " React with the \u267b (recycle) emoji to request this media from your phone."
- }
-
- return portal.makeMediaBridgeFailureMessage(info, err, converted, &FailedMediaKeys{
- Key: msg.GetMediaKey(),
- Length: int(msg.GetFileLength()),
- Type: whatsmeow.GetMediaType(msg),
- SHA256: msg.GetFileSHA256(),
- EncSHA256: msg.GetFileEncSHA256(),
- }, errorText)
- } else if errors.Is(err, whatsmeow.ErrNoURLPresent) {
- zerolog.Ctx(ctx).Debug().Msg("No URL present error for media message, ignoring...")
- return nil
- } else 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 portal.makeMediaBridgeFailureMessage(info, err, converted, nil, "")
- }
-
- err = portal.uploadMedia(ctx, intent, data, converted.Content)
- if err != nil {
- if errors.Is(err, mautrix.MTooLarge) {
- return portal.makeMediaBridgeFailureMessage(info, errors.New("homeserver rejected too large file"), converted, nil, "")
- } else if httpErr := (mautrix.HTTPError{}); errors.As(err, &httpErr) && httpErr.IsStatus(413) {
- return portal.makeMediaBridgeFailureMessage(info, errors.New("proxy rejected too large file"), converted, nil, "")
- } else {
- return portal.makeMediaBridgeFailureMessage(info, fmt.Errorf("failed to upload media: %w", err), converted, nil, "")
- }
- }
- return converted
-}
-
-func (portal *Portal) fetchMediaRetryEvent(ctx context.Context, msg *database.Message) (*FailedMediaMeta, error) {
- errorMeta, ok := portal.mediaErrorCache[msg.JID]
- if ok {
- return errorMeta, nil
- }
- evt, err := portal.MainIntent().GetEvent(ctx, portal.MXID, msg.MXID)
- if err != nil {
- return nil, fmt.Errorf("failed to fetch event %s: %w", msg.MXID, err)
- }
- if evt.Type == event.EventEncrypted {
- err = evt.Content.ParseRaw(evt.Type)
- if err != nil {
- return nil, fmt.Errorf("failed to parse encrypted content in %s: %w", msg.MXID, err)
- }
- evt, err = portal.bridge.Crypto.Decrypt(ctx, evt)
- if err != nil {
- return nil, fmt.Errorf("failed to decrypt event %s: %w", msg.MXID, err)
- }
- }
- errorMetaResult := gjson.GetBytes(evt.Content.VeryRaw, strings.ReplaceAll(failedMediaField, ".", "\\."))
- if !errorMetaResult.Exists() || !errorMetaResult.IsObject() {
- return nil, fmt.Errorf("didn't find failed media metadata in %s", msg.MXID)
- }
- var errorMetaBytes []byte
- if errorMetaResult.Index > 0 {
- errorMetaBytes = evt.Content.VeryRaw[errorMetaResult.Index : errorMetaResult.Index+len(errorMetaResult.Raw)]
- } else {
- errorMetaBytes = []byte(errorMetaResult.Raw)
- }
- err = json.Unmarshal(errorMetaBytes, &errorMeta)
- if err != nil {
- return nil, fmt.Errorf("failed to unmarshal failed media metadata in %s: %w", msg.MXID, err)
- }
- return errorMeta, nil
-}
-
-func (portal *Portal) sendMediaRetryFailureEdit(ctx context.Context, intent *appservice.IntentAPI, msg *database.Message, err error) {
- content := event.MessageEventContent{
- MsgType: event.MsgNotice,
- Body: fmt.Sprintf("Failed to bridge media after re-requesting it from your phone: %v", err),
- }
- contentCopy := content
- content.NewContent = &contentCopy
- content.RelatesTo = &event.RelatesTo{
- EventID: msg.MXID,
- Type: event.RelReplace,
- }
- resp, sendErr := portal.sendMessage(ctx, intent, event.EventMessage, &content, nil, time.Now().UnixMilli())
- if sendErr != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to edit message after media retry failure")
- } else {
- zerolog.Ctx(ctx).Debug().Stringer("edit_mxid", resp.EventID).
- Msg("Successfully edited message after media retry failure")
- }
-}
-
-func (portal *Portal) handleMediaRetry(retry *events.MediaRetry, source *User) {
- log := portal.zlog.With().
- Str("action", "handle media retry").
- Str("retry_message_id", retry.MessageID).
- Logger()
- ctx := log.WithContext(context.TODO())
- err := source.mediaRetryLock.Acquire(ctx, 1)
- if err != nil {
- log.Err(err).Msg("Failed to acquire media retry semaphore")
- return
- }
- defer source.mediaRetryLock.Release(1)
-
- msg, err := portal.bridge.DB.Message.GetByJID(ctx, portal.Key, retry.MessageID)
- if msg == nil {
- log.Warn().Msg("Dropping media retry notification for unknown message")
- return
- }
- log.UpdateContext(func(c zerolog.Context) zerolog.Context {
- return c.Stringer("retry_message_mxid", msg.MXID)
- })
- if msg.Error != database.MsgErrMediaNotFound {
- log.Warn().Msg("Dropping media retry notification for non-errored message")
- return
- }
-
- meta, err := portal.fetchMediaRetryEvent(ctx, msg)
- if err != nil {
- log.Warn().Err(err).Msg("Can't handle media retry notification for message")
- return
- }
-
- var puppet *Puppet
- if retry.FromMe {
- puppet = portal.bridge.GetPuppetByJID(source.JID)
- } else if retry.ChatID.Server == types.DefaultUserServer {
- puppet = portal.bridge.GetPuppetByJID(retry.ChatID)
- } else {
- puppet = portal.bridge.GetPuppetByJID(retry.SenderID)
- }
- if puppet == nil {
- // TODO handle lids?
- return
- }
- intent := puppet.IntentFor(portal)
-
- retryData, err := whatsmeow.DecryptMediaRetryNotification(retry, meta.Media.Key)
- if err != nil {
- log.Warn().Err(err).Msg("Failed to decrypt media retry notification")
- portal.sendMediaRetryFailureEdit(ctx, intent, msg, err)
- return
- } else if retryData.GetResult() != waProto.MediaRetryNotification_SUCCESS {
- errorName := waMmsRetry.MediaRetryNotification_ResultType_name[int32(retryData.GetResult())]
- if retryData.GetDirectPath() == "" {
- log.Warn().Str("error_name", errorName).Msg("Got error response in media retry notification")
- log.Debug().Any("error_content", retryData).Msg("Full error response content")
- if retryData.GetResult() == waProto.MediaRetryNotification_NOT_FOUND {
- portal.sendMediaRetryFailureEdit(ctx, intent, msg, whatsmeow.ErrMediaNotAvailableOnPhone)
- } else {
- portal.sendMediaRetryFailureEdit(ctx, intent, msg, fmt.Errorf("phone sent error response: %s", errorName))
- }
- return
- } else {
- log.Debug().Msg("Got error response in media retry notification, but response also contains a new download URL - trying to download")
- }
- }
-
- data, err := source.Client.DownloadMediaWithPath(retryData.GetDirectPath(), meta.Media.EncSHA256, meta.Media.SHA256, meta.Media.Key, meta.Media.Length, meta.Media.Type, "")
- if err != nil {
- log.Warn().Err(err).Msg("Failed to download media after retry notification")
- portal.sendMediaRetryFailureEdit(ctx, intent, msg, err)
- return
- }
- err = portal.uploadMedia(ctx, intent, data, meta.Content)
- if err != nil {
- log.Err(err).Msg("Failed to re-upload media after retry notification")
- portal.sendMediaRetryFailureEdit(ctx, intent, msg, fmt.Errorf("re-uploading media failed: %v", err))
- return
- }
- replaceContent := &event.MessageEventContent{
- MsgType: meta.Content.MsgType,
- Body: "* " + meta.Content.Body,
- NewContent: meta.Content,
- RelatesTo: &event.RelatesTo{
- EventID: msg.MXID,
- Type: event.RelReplace,
- },
- }
- // Move the extra content into m.new_content too
- meta.ExtraContent = map[string]interface{}{
- "m.new_content": maps.Clone(meta.ExtraContent),
- }
- resp, err := portal.sendMessage(ctx, intent, meta.Type, replaceContent, meta.ExtraContent, time.Now().UnixMilli())
- if err != nil {
- log.Err(err).Msg("Failed to edit message after reuploading media from retry notification")
- return
- }
- log.Debug().Stringer("edit_mxid", resp.EventID).Msg("Successfully edited message after retry notification")
- err = msg.UpdateMXID(ctx, resp.EventID, database.MsgNormal, database.MsgNoError)
- if err != nil {
- log.Err(err).Msg("Failed to save message to database after editing with retry notification")
- }
-}
-
-func (portal *Portal) requestMediaRetry(ctx context.Context, user *User, eventID id.EventID, mediaKey []byte) (bool, error) {
- log := zerolog.Ctx(ctx).With().Stringer("target_event_id", eventID).Logger()
- msg, err := portal.bridge.DB.Message.GetByMXID(ctx, eventID)
- if err != nil {
- log.Err(err).Msg("Failed to get media retry target from database")
- return false, fmt.Errorf("failed to get media retry target")
- } else if msg == nil {
- log.Debug().Msg("Can't send media retry request for unknown message")
- return false, fmt.Errorf("unknown message")
- }
- log.UpdateContext(func(c zerolog.Context) zerolog.Context {
- return c.Str("target_message_id", msg.JID)
- })
- if msg.Error != database.MsgErrMediaNotFound {
- log.Debug().Msg("Dropping media retry request for non-errored message")
- return false, fmt.Errorf("message is not errored")
- }
-
- // If the media key is not provided, grab it from the event in Matrix
- if mediaKey == nil {
- evt, err := portal.fetchMediaRetryEvent(ctx, msg)
- if err != nil {
- log.Warn().Err(err).Msg("Dropping media retry request as media key couldn't be fetched")
- return true, nil
- }
- mediaKey = evt.Media.Key
- }
-
- err = user.Client.SendMediaRetryReceipt(&types.MessageInfo{
- ID: msg.JID,
- MessageSource: types.MessageSource{
- IsFromMe: msg.Sender.User == user.JID.User,
- IsGroup: !portal.IsPrivateChat(),
- Sender: msg.Sender,
- Chat: portal.Key.JID,
- },
- }, mediaKey)
- if err != nil {
- log.Err(err).Msg("Failed to send media retry request")
- } else {
- log.Debug().Msg("Sent media retry request")
- }
- return true, err
-}
-
-const thumbnailMaxSize = 72
-const thumbnailMinSize = 24
-
-func createThumbnailAndGetSize(source []byte, pngThumbnail bool) ([]byte, int, int, error) {
- src, _, err := image.Decode(bytes.NewReader(source))
- if err != nil {
- return nil, 0, 0, fmt.Errorf("failed to decode thumbnail: %w", err)
- }
- imageBounds := src.Bounds()
- width, height := imageBounds.Max.X, imageBounds.Max.Y
- var img image.Image
- if width <= thumbnailMaxSize && height <= thumbnailMaxSize {
- // No need to resize
- img = src
- } else {
- if width == height {
- width = thumbnailMaxSize
- height = thumbnailMaxSize
- } else if width < height {
- width /= height / thumbnailMaxSize
- height = thumbnailMaxSize
- } else {
- height /= width / thumbnailMaxSize
- width = thumbnailMaxSize
- }
- if width < thumbnailMinSize {
- width = thumbnailMinSize
- }
- if height < thumbnailMinSize {
- height = thumbnailMinSize
- }
- dst := image.NewRGBA(image.Rect(0, 0, width, height))
- draw.NearestNeighbor.Scale(dst, dst.Rect, src, src.Bounds(), draw.Over, nil)
- img = dst
- }
-
- var buf bytes.Buffer
- if pngThumbnail {
- err = png.Encode(&buf, img)
- } else {
- err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpeg.DefaultQuality})
- }
- if err != nil {
- return nil, width, height, fmt.Errorf("failed to re-encode thumbnail: %w", err)
- }
- return buf.Bytes(), width, height, nil
-}
-
-func createThumbnail(source []byte, png bool) ([]byte, error) {
- data, _, _, err := createThumbnailAndGetSize(source, png)
- return data, err
-}
-
-func (portal *Portal) downloadThumbnail(ctx context.Context, original []byte, thumbnailURL id.ContentURIString, eventID id.EventID, png bool) ([]byte, error) {
- if len(thumbnailURL) == 0 {
- // just fall back to making thumbnail of original
- } else if mxc, err := thumbnailURL.Parse(); err != nil {
- zerolog.Ctx(ctx).Warn().Err(err).Msg("Malformed thumbnail URL in event, falling back to generating thumbnail from source")
- } else if thumbnail, err := portal.MainIntent().DownloadBytes(ctx, mxc); err != nil {
- zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to download thumbnail in event, falling back to generating thumbnail from source")
- } else {
- return createThumbnail(thumbnail, png)
- }
- return createThumbnail(original, png)
-}
-
-func (portal *Portal) 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 pngBuffer bytes.Buffer
- if err = png.Encode(&pngBuffer, webpDecoded); err != nil {
- return nil, fmt.Errorf("failed to encode png image: %w", err)
- }
-
- return pngBuffer.Bytes(), nil
-}
-
-type PaddedImage struct {
- image.Image
- Size int
- OffsetX int
- OffsetY int
-}
-
-func (img *PaddedImage) Bounds() image.Rectangle {
- return image.Rect(0, 0, img.Size, img.Size)
-}
-
-func (img *PaddedImage) At(x, y int) color.Color {
- return img.Image.At(x+img.OffsetX, y+img.OffsetY)
-}
-
-func (portal *Portal) convertToWebP(img []byte) ([]byte, error) {
- decodedImg, _, err := image.Decode(bytes.NewReader(img))
- if err != nil {
- return img, fmt.Errorf("failed to decode image: %w", err)
- }
-
- bounds := decodedImg.Bounds()
- width, height := bounds.Dx(), bounds.Dy()
- if width != height {
- paddedImg := &PaddedImage{
- Image: decodedImg,
- OffsetX: bounds.Min.Y,
- OffsetY: bounds.Min.X,
- }
- if width > height {
- paddedImg.Size = width
- paddedImg.OffsetY -= (paddedImg.Size - height) / 2
- } else {
- paddedImg.Size = height
- paddedImg.OffsetX -= (paddedImg.Size - width) / 2
- }
- decodedImg = paddedImg
- }
-
- var webpBuffer bytes.Buffer
- if err = cwebp.Encode(&webpBuffer, decodedImg, nil); err != nil {
- return img, fmt.Errorf("failed to encode webp image: %w", err)
- }
-
- return webpBuffer.Bytes(), nil
-}
-
-func (portal *Portal) preprocessMatrixMedia(ctx context.Context, sender *User, relaybotFormatted bool, content *event.MessageEventContent, eventID id.EventID, mediaType whatsmeow.MediaType) (*MediaUpload, error) {
- fileName := content.Body
- var caption string
- var mentionedJIDs []string
- var hasHTMLCaption bool
- isSticker := string(content.MsgType) == event.EventSticker.Type
- if content.FileName != "" && content.Body != content.FileName {
- fileName = content.FileName
- caption = content.Body
- hasHTMLCaption = content.Format == event.FormatHTML
- }
- if relaybotFormatted || hasHTMLCaption {
- caption, mentionedJIDs = portal.bridge.Formatter.ParseMatrix(content.FormattedBody, content.Mentions)
- }
-
- var file *event.EncryptedFileInfo
- rawMXC := content.URL
- if content.File != nil {
- file = content.File
- rawMXC = file.URL
- }
- mxc, err := rawMXC.Parse()
- if err != nil {
- return nil, err
- }
- data, err := portal.MainIntent().DownloadBytes(ctx, mxc)
- if err != nil {
- return nil, exerrors.NewDualError(errMediaDownloadFailed, err)
- }
- if file != nil {
- err = file.DecryptInPlace(data)
- if err != nil {
- return nil, exerrors.NewDualError(errMediaDecryptFailed, err)
- }
- }
- mimeType := content.GetInfo().MimeType
- if mimeType == "" {
- content.Info.MimeType = "application/octet-stream"
- }
- var convertErr error
- // Allowed mime types from https://developers.facebook.com/docs/whatsapp/on-premises/reference/media
- switch {
- case isSticker:
- if mimeType != "image/webp" || content.Info.Width != content.Info.Height {
- data, convertErr = portal.convertToWebP(data)
- content.Info.MimeType = "image/webp"
- }
- case mediaType == whatsmeow.MediaVideo:
- switch mimeType {
- case "video/mp4", "video/3gpp":
- // Allowed
- case "image/gif":
- data, convertErr = ffmpeg.ConvertBytes(ctx, data, ".mp4", []string{"-f", "gif"}, []string{
- "-pix_fmt", "yuv420p", "-c:v", "libx264", "-movflags", "+faststart",
- "-filter:v", "crop='floor(in_w/2)*2:floor(in_h/2)*2'",
- }, mimeType)
- content.Info.MimeType = "video/mp4"
- case "video/webm":
- data, convertErr = ffmpeg.ConvertBytes(ctx, data, ".mp4", []string{"-f", "webm"}, []string{
- "-pix_fmt", "yuv420p", "-c:v", "libx264",
- }, mimeType)
- content.Info.MimeType = "video/mp4"
- default:
- return nil, fmt.Errorf("%w %q in video message", errMediaUnsupportedType, mimeType)
- }
- case mediaType == whatsmeow.MediaImage:
- switch mimeType {
- case "image/jpeg", "image/png":
- // Allowed
- case "image/webp":
- data, convertErr = portal.convertWebPtoPNG(data)
- content.Info.MimeType = "image/png"
- default:
- return nil, fmt.Errorf("%w %q in image message", errMediaUnsupportedType, mimeType)
- }
- case mediaType == whatsmeow.MediaAudio:
- switch mimeType {
- case "audio/aac", "audio/mp4", "audio/amr", "audio/mpeg", "audio/ogg; codecs=opus":
- // Allowed
- case "audio/ogg":
- // Hopefully it's opus already
- content.Info.MimeType = "audio/ogg; codecs=opus"
- default:
- return nil, fmt.Errorf("%w %q in audio message", errMediaUnsupportedType, mimeType)
- }
- case mediaType == whatsmeow.MediaDocument:
- // Everything is allowed
- }
- if convertErr != nil {
- if content.Info.MimeType != mimeType || data == nil {
- return nil, exerrors.NewDualError(fmt.Errorf("%w (%s to %s)", errMediaConvertFailed, mimeType, content.Info.MimeType), convertErr)
- } else {
- // If the mime type didn't change and the errored conversion function returned the original data, just log a warning and continue
- zerolog.Ctx(ctx).Warn().Err(convertErr).Str("source_mime", mimeType).Msg("Failed to re-encode media, continuing with original file")
- }
- }
- var uploadResp whatsmeow.UploadResponse
- if portal.Key.JID.Server == types.NewsletterServer {
- uploadResp, err = sender.Client.UploadNewsletter(ctx, data, mediaType)
- } else {
- uploadResp, err = sender.Client.Upload(ctx, data, mediaType)
- }
- if err != nil {
- return nil, exerrors.NewDualError(errMediaWhatsAppUploadFailed, err)
- }
-
- // Audio doesn't have thumbnails
- var thumbnail []byte
- if mediaType != whatsmeow.MediaAudio {
- thumbnail, err = portal.downloadThumbnail(ctx, data, content.GetInfo().ThumbnailURL, eventID, isSticker)
- // Ignore format errors for non-image files, we don't care about those thumbnails
- if err != nil && (!errors.Is(err, image.ErrFormat) || mediaType == whatsmeow.MediaImage) {
- zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to generate thumbnail for image message")
- }
- }
-
- return &MediaUpload{
- UploadResponse: uploadResp,
- FileName: fileName,
- Caption: caption,
- MentionedJIDs: mentionedJIDs,
- Thumbnail: thumbnail,
- FileLength: len(data),
- }, nil
-}
-
-type MediaUpload struct {
- whatsmeow.UploadResponse
- Caption string
- FileName string
- MentionedJIDs []string
- Thumbnail []byte
- FileLength int
-}
-
-func (portal *Portal) addRelaybotFormat(ctx context.Context, userID id.UserID, content *event.MessageEventContent) bool {
- member := portal.MainIntent().Member(ctx, portal.MXID, userID)
- if member == nil {
- member = &event.MemberEventContent{}
- }
- content.EnsureHasHTML()
- data, err := portal.bridge.Config.Bridge.Relay.FormatMessage(content, userID, *member)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to apply relaybot format")
- }
- content.FormattedBody = data
- return true
-}
-
-func addCodecToMime(mimeType, codec string) string {
- mediaType, params, err := mime.ParseMediaType(mimeType)
- if err != nil {
- return mimeType
- }
- if _, ok := params["codecs"]; !ok {
- params["codecs"] = codec
- }
- return mime.FormatMediaType(mediaType, params)
-}
-
-func parseGeoURI(uri string) (lat, long float64, err error) {
- if !strings.HasPrefix(uri, "geo:") {
- err = fmt.Errorf("uri doesn't have geo: prefix")
- return
- }
- // Remove geo: prefix and anything after ;
- coordinates := strings.Split(strings.TrimPrefix(uri, "geo:"), ";")[0]
-
- if splitCoordinates := strings.Split(coordinates, ","); len(splitCoordinates) != 2 {
- err = fmt.Errorf("didn't find exactly two numbers separated by a comma")
- } else if lat, err = strconv.ParseFloat(splitCoordinates[0], 64); err != nil {
- err = fmt.Errorf("latitude is not a number: %w", err)
- } else if long, err = strconv.ParseFloat(splitCoordinates[1], 64); err != nil {
- err = fmt.Errorf("longitude is not a number: %w", err)
- }
- return
-}
-
-func getUnstableWaveform(content map[string]interface{}) []byte {
- audioInfo, ok := content["org.matrix.msc1767.audio"].(map[string]interface{})
- if !ok {
- return nil
- }
- waveform, ok := audioInfo["waveform"].([]interface{})
- if !ok {
- return nil
- }
- output := make([]byte, len(waveform))
- var val float64
- for i, part := range waveform {
- val, ok = part.(float64)
- if ok {
- output[i] = byte(val / 4)
- }
- }
- return output
-}
-
-var (
- TypeMSC3381PollStart = event.Type{Class: event.MessageEventType, Type: "org.matrix.msc3381.poll.start"}
- TypeMSC3381PollResponse = event.Type{Class: event.MessageEventType, Type: "org.matrix.msc3381.poll.response"}
- TypeMSC3381V2PollResponse = event.Type{Class: event.MessageEventType, Type: "org.matrix.msc3381.v2.poll.response"}
-)
-
-type PollResponseContent struct {
- RelatesTo event.RelatesTo `json:"m.relates_to"`
- V1Response struct {
- Answers []string `json:"answers"`
- } `json:"org.matrix.msc3381.poll.response"`
- V2Selections []string `json:"org.matrix.msc3381.v2.selections"`
-}
-
-func (content *PollResponseContent) GetRelatesTo() *event.RelatesTo {
- return &content.RelatesTo
-}
-
-func (content *PollResponseContent) OptionalGetRelatesTo() *event.RelatesTo {
- if content.RelatesTo.Type == "" {
- return nil
- }
- return &content.RelatesTo
-}
-
-func (content *PollResponseContent) SetRelatesTo(rel *event.RelatesTo) {
- content.RelatesTo = *rel
-}
-
-type MSC1767Message struct {
- Text string `json:"org.matrix.msc1767.text,omitempty"`
- HTML string `json:"org.matrix.msc1767.html,omitempty"`
- Message []struct {
- MimeType string `json:"mimetype"`
- Body string `json:"body"`
- } `json:"org.matrix.msc1767.message,omitempty"`
-}
-
-func (portal *Portal) msc1767ToWhatsApp(msg MSC1767Message, mentions bool) (string, []string) {
- for _, part := range msg.Message {
- if part.MimeType == "text/html" && msg.HTML == "" {
- msg.HTML = part.Body
- } else if part.MimeType == "text/plain" && msg.Text == "" {
- msg.Text = part.Body
- }
- }
- if msg.HTML != "" {
- if mentions {
- return portal.bridge.Formatter.ParseMatrix(msg.HTML, nil)
- } else {
- return portal.bridge.Formatter.ParseMatrixWithoutMentions(msg.HTML), nil
- }
- }
- return msg.Text, nil
-}
-
-type PollStartContent struct {
- RelatesTo *event.RelatesTo `json:"m.relates_to"`
- PollStart struct {
- Kind string `json:"kind"`
- MaxSelections int `json:"max_selections"`
- Question MSC1767Message `json:"question"`
- Answers []struct {
- ID string `json:"id"`
- MSC1767Message
- } `json:"answers"`
- } `json:"org.matrix.msc3381.poll.start"`
-}
-
-func (content *PollStartContent) GetRelatesTo() *event.RelatesTo {
- if content.RelatesTo == nil {
- content.RelatesTo = &event.RelatesTo{}
- }
- return content.RelatesTo
-}
-
-func (content *PollStartContent) OptionalGetRelatesTo() *event.RelatesTo {
- return content.RelatesTo
-}
-
-func (content *PollStartContent) SetRelatesTo(rel *event.RelatesTo) {
- content.RelatesTo = rel
-}
-
-func init() {
- event.TypeMap[TypeMSC3381PollResponse] = reflect.TypeOf(PollResponseContent{})
- event.TypeMap[TypeMSC3381V2PollResponse] = reflect.TypeOf(PollResponseContent{})
- event.TypeMap[TypeMSC3381PollStart] = reflect.TypeOf(PollStartContent{})
-}
-
-func (portal *Portal) convertMatrixPollVote(ctx context.Context, sender *User, evt *event.Event) (*waProto.Message, *User, *extraConvertMeta, error) {
- content, ok := evt.Content.Parsed.(*PollResponseContent)
- if !ok {
- return nil, sender, nil, fmt.Errorf("%w %T", errUnexpectedParsedContentType, evt.Content.Parsed)
- }
- var answers []string
- if content.V1Response.Answers != nil {
- answers = content.V1Response.Answers
- } else if content.V2Selections != nil {
- answers = content.V2Selections
- }
- log := zerolog.Ctx(ctx)
- pollMsg, err := portal.bridge.DB.Message.GetByMXID(ctx, content.RelatesTo.EventID)
- if err != nil {
- log.Err(err).Msg("Failed to get poll message from database")
- return nil, sender, nil, fmt.Errorf("failed to get poll message")
- } else if pollMsg == nil {
- return nil, sender, nil, errTargetNotFound
- }
- pollMsgInfo := &types.MessageInfo{
- MessageSource: types.MessageSource{
- Chat: portal.Key.JID,
- Sender: pollMsg.Sender,
- IsFromMe: pollMsg.Sender.User == sender.JID.User,
- IsGroup: portal.IsGroupChat(),
- },
- ID: pollMsg.JID,
- Type: "poll",
- }
- optionHashes := make([][]byte, 0, len(answers))
- if pollMsg.Type == database.MsgMatrixPoll {
- mappedAnswers, err := pollMsg.GetPollOptionHashes(ctx, answers)
- if err != nil {
- log.Err(err).Msg("Failed to get poll option hashes from database")
- return nil, sender, nil, fmt.Errorf("failed to get poll option hashes")
- }
- for _, selection := range answers {
- hash, ok := mappedAnswers[selection]
- if ok {
- optionHashes = append(optionHashes, hash[:])
- } else {
- log.Warn().Str("option", selection).Msg("Didn't find hash for selected option")
- }
- }
- } else {
- for _, selection := range answers {
- hash, _ := hex.DecodeString(selection)
- if hash != nil && len(hash) == 32 {
- optionHashes = append(optionHashes, hash)
- }
- }
- }
- pollUpdate, err := sender.Client.EncryptPollVote(pollMsgInfo, &waProto.PollVoteMessage{
- SelectedOptions: optionHashes,
- })
- return &waProto.Message{PollUpdateMessage: pollUpdate}, sender, nil, err
-}
-
-func (portal *Portal) convertMatrixPollStart(ctx context.Context, sender *User, evt *event.Event) (*waProto.Message, *User, *extraConvertMeta, error) {
- content, ok := evt.Content.Parsed.(*PollStartContent)
- if !ok {
- return nil, sender, nil, fmt.Errorf("%w %T", errUnexpectedParsedContentType, evt.Content.Parsed)
- }
- maxAnswers := content.PollStart.MaxSelections
- if maxAnswers >= len(content.PollStart.Answers) || maxAnswers < 0 {
- maxAnswers = 0
- }
- ctxInfo := portal.generateContextInfo(ctx, content.RelatesTo)
- var question string
- question, ctxInfo.MentionedJID = portal.msc1767ToWhatsApp(content.PollStart.Question, true)
- if len(question) == 0 {
- return nil, sender, nil, errPollMissingQuestion
- }
- options := make([]*waProto.PollCreationMessage_Option, len(content.PollStart.Answers))
- optionMap := make(map[[32]byte]string, len(options))
- for i, opt := range content.PollStart.Answers {
- body, _ := portal.msc1767ToWhatsApp(opt.MSC1767Message, false)
- hash := sha256.Sum256([]byte(body))
- if _, alreadyExists := optionMap[hash]; alreadyExists {
- zerolog.Ctx(ctx).Warn().Str("option", body).Msg("Poll has duplicate options, rejecting")
- return nil, sender, nil, errPollDuplicateOption
- }
- optionMap[hash] = opt.ID
- options[i] = &waProto.PollCreationMessage_Option{
- OptionName: proto.String(body),
- }
- }
- secret := make([]byte, 32)
- _, err := rand.Read(secret)
- return &waProto.Message{
- PollCreationMessage: &waProto.PollCreationMessage{
- Name: proto.String(question),
- Options: options,
- SelectableOptionsCount: proto.Uint32(uint32(maxAnswers)),
- ContextInfo: ctxInfo,
- },
- MessageContextInfo: &waProto.MessageContextInfo{
- MessageSecret: secret,
- },
- }, sender, &extraConvertMeta{PollOptions: optionMap}, err
-}
-
-func (portal *Portal) generateContextInfo(ctx context.Context, relatesTo *event.RelatesTo) *waProto.ContextInfo {
- var ctxInfo waProto.ContextInfo
- replyToID := relatesTo.GetReplyTo()
- if len(replyToID) > 0 {
- replyToMsg, err := portal.bridge.DB.Message.GetByMXID(ctx, replyToID)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).
- Stringer("reply_to_mxid", replyToID).
- Msg("Failed to get reply target from database")
- }
- if replyToMsg != nil && !replyToMsg.IsFakeJID() && (replyToMsg.Type == database.MsgNormal || replyToMsg.Type == database.MsgMatrixPoll || replyToMsg.Type == database.MsgBeeperGallery) {
- ctxInfo.StanzaID = &replyToMsg.JID
- ctxInfo.Participant = proto.String(replyToMsg.Sender.ToNonAD().String())
- // Using blank content here seems to work fine on all official WhatsApp apps.
- //
- // We could probably invent a slightly more accurate version of the quoted message
- // by fetching the Matrix event and converting it to the WhatsApp format, but that's
- // a lot of work and this works fine.
- ctxInfo.QuotedMessage = &waProto.Message{Conversation: proto.String("")}
- }
- }
- if portal.ExpirationTime != 0 {
- ctxInfo.Expiration = proto.Uint32(portal.ExpirationTime)
- }
- return &ctxInfo
-}
-
-type extraConvertMeta struct {
- PollOptions map[[32]byte]string
- EditRootMsg *database.Message
-
- GalleryExtraParts []*waProto.Message
-
- MediaHandle string
-}
-
-func getEditError(rootMsg *database.Message, editer *User) error {
- switch {
- case rootMsg == nil:
- return errEditUnknownTarget
- case rootMsg.Type != database.MsgNormal || rootMsg.IsFakeJID():
- return errEditUnknownTargetType
- case rootMsg.Sender.User != editer.JID.User:
- return errEditDifferentSender
- case time.Since(rootMsg.Timestamp) > whatsmeow.EditWindow:
- return errEditTooOld
- default:
- return nil
- }
-}
-
-func (portal *Portal) convertMatrixMessage(ctx context.Context, sender *User, evt *event.Event) (*waProto.Message, *User, *extraConvertMeta, error) {
- if evt.Type == TypeMSC3381PollResponse || evt.Type == TypeMSC3381V2PollResponse {
- return portal.convertMatrixPollVote(ctx, sender, evt)
- } else if evt.Type == TypeMSC3381PollStart {
- return portal.convertMatrixPollStart(ctx, sender, evt)
- }
- content, ok := evt.Content.Parsed.(*event.MessageEventContent)
- if !ok {
- return nil, sender, nil, fmt.Errorf("%w %T", errUnexpectedParsedContentType, evt.Content.Parsed)
- }
- extraMeta := &extraConvertMeta{}
- realSenderMXID := sender.MXID
- isRelay := false
- if !sender.IsLoggedIn() || (portal.IsPrivateChat() && sender.JID.User != portal.Key.Receiver.User) {
- if !portal.HasRelaybot() {
- return nil, sender, extraMeta, errUserNotLoggedIn
- }
- sender = portal.GetRelayUser()
- if !sender.IsLoggedIn() {
- return nil, sender, extraMeta, errRelaybotNotLoggedIn
- }
- isRelay = true
- }
- log := zerolog.Ctx(ctx)
- var editRootMsg *database.Message
- if editEventID := content.RelatesTo.GetReplaceID(); editEventID != "" {
- log.UpdateContext(func(c zerolog.Context) zerolog.Context {
- return c.Stringer("edit_target_mxid", editEventID)
- })
- var err error
- editRootMsg, err = portal.bridge.DB.Message.GetByMXID(ctx, editEventID)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to get edit target message from database")
- return nil, sender, extraMeta, errEditUnknownTarget
- } else if editErr := getEditError(editRootMsg, sender); editErr != nil {
- return nil, sender, extraMeta, editErr
- }
- log.UpdateContext(func(c zerolog.Context) zerolog.Context {
- return c.Str("edit_target_id", editRootMsg.JID)
- })
- extraMeta.EditRootMsg = editRootMsg
- if content.NewContent != nil {
- content = content.NewContent
- }
- }
-
- msg := &waProto.Message{}
- ctxInfo := portal.generateContextInfo(ctx, content.RelatesTo)
- relaybotFormatted := isRelay && portal.addRelaybotFormat(ctx, realSenderMXID, content)
- if evt.Type == event.EventSticker {
- if relaybotFormatted {
- // Stickers can't have captions, so force relaybot stickers to be images
- content.MsgType = event.MsgImage
- } else {
- content.MsgType = event.MessageType(event.EventSticker.Type)
- }
- }
- if content.MsgType == event.MsgImage && content.GetInfo().MimeType == "image/gif" {
- content.MsgType = event.MsgVideo
- }
- if content.MsgType == event.MsgAudio && content.FileName != "" && content.Body != content.FileName {
- // Send audio messages with captions as files since WhatsApp doesn't support captions on audio messages
- content.MsgType = event.MsgFile
- }
-
- switch content.MsgType {
- case event.MsgText, event.MsgEmote, event.MsgNotice:
- text := content.Body
- if content.MsgType == event.MsgNotice && !portal.bridge.Config.Bridge.BridgeNotices {
- return nil, sender, extraMeta, errMNoticeDisabled
- }
- if content.Format == event.FormatHTML {
- text, ctxInfo.MentionedJID = portal.bridge.Formatter.ParseMatrix(content.FormattedBody, content.Mentions)
- }
- if content.MsgType == event.MsgEmote && !relaybotFormatted {
- text = "/me " + text
- }
- msg.ExtendedTextMessage = &waProto.ExtendedTextMessage{
- Text: &text,
- ContextInfo: ctxInfo,
- }
- hasPreview := portal.convertURLPreviewToWhatsApp(ctx, sender, content, msg.ExtendedTextMessage)
- if ctx.Err() != nil {
- return nil, sender, extraMeta, ctx.Err()
- }
- if ctxInfo.StanzaID == nil && ctxInfo.MentionedJID == nil && ctxInfo.Expiration == nil && !hasPreview {
- // No need for extended message
- msg.ExtendedTextMessage = nil
- msg.Conversation = &text
- }
- case event.MsgImage:
- media, err := portal.preprocessMatrixMedia(ctx, sender, relaybotFormatted, content, evt.ID, whatsmeow.MediaImage)
- if media == nil {
- return nil, sender, extraMeta, err
- }
- extraMeta.MediaHandle = media.Handle
- ctxInfo.MentionedJID = media.MentionedJIDs
- msg.ImageMessage = &waProto.ImageMessage{
- ContextInfo: ctxInfo,
- Caption: &media.Caption,
- JPEGThumbnail: media.Thumbnail,
- URL: &media.URL,
- DirectPath: &media.DirectPath,
- MediaKey: media.MediaKey,
- Mimetype: &content.GetInfo().MimeType,
- FileEncSHA256: media.FileEncSHA256,
- FileSHA256: media.FileSHA256,
- FileLength: proto.Uint64(uint64(media.FileLength)),
- }
- case event.MsgBeeperGallery:
- if isRelay {
- return nil, sender, extraMeta, errGalleryRelay
- } else if content.BeeperGalleryCaption != "" {
- return nil, sender, extraMeta, errGalleryCaption
- } else if portal.Key.JID.Server == types.NewsletterServer {
- // We don't handle the media handles properly for multiple messages
- return nil, sender, extraMeta, fmt.Errorf("can't send gallery to newsletter")
- }
- for i, part := range content.BeeperGalleryImages {
- // TODO support videos
- media, err := portal.preprocessMatrixMedia(ctx, sender, false, part, evt.ID, whatsmeow.MediaImage)
- if media == nil {
- return nil, sender, extraMeta, fmt.Errorf("failed to handle image #%d: %w", i+1, err)
- }
- imageMsg := &waProto.ImageMessage{
- ContextInfo: ctxInfo,
- JPEGThumbnail: media.Thumbnail,
- URL: &media.URL,
- DirectPath: &media.DirectPath,
- MediaKey: media.MediaKey,
- Mimetype: &part.GetInfo().MimeType,
- FileEncSHA256: media.FileEncSHA256,
- FileSHA256: media.FileSHA256,
- FileLength: proto.Uint64(uint64(media.FileLength)),
- }
- if i == 0 {
- msg.ImageMessage = imageMsg
- } else {
- extraMeta.GalleryExtraParts = append(extraMeta.GalleryExtraParts, &waProto.Message{
- ImageMessage: imageMsg,
- })
- }
- }
- case event.MessageType(event.EventSticker.Type):
- media, err := portal.preprocessMatrixMedia(ctx, sender, relaybotFormatted, content, evt.ID, whatsmeow.MediaImage)
- if media == nil {
- return nil, sender, extraMeta, err
- }
- extraMeta.MediaHandle = media.Handle
- ctxInfo.MentionedJID = media.MentionedJIDs
- msg.StickerMessage = &waProto.StickerMessage{
- ContextInfo: ctxInfo,
- PngThumbnail: media.Thumbnail,
- URL: &media.URL,
- DirectPath: &media.DirectPath,
- MediaKey: media.MediaKey,
- Mimetype: &content.GetInfo().MimeType,
- FileEncSHA256: media.FileEncSHA256,
- FileSHA256: media.FileSHA256,
- FileLength: proto.Uint64(uint64(media.FileLength)),
- }
- case event.MsgVideo:
- gifPlayback := content.GetInfo().MimeType == "image/gif"
- media, err := portal.preprocessMatrixMedia(ctx, sender, relaybotFormatted, content, evt.ID, whatsmeow.MediaVideo)
- if media == nil {
- return nil, sender, extraMeta, err
- }
- duration := uint32(content.GetInfo().Duration / 1000)
- extraMeta.MediaHandle = media.Handle
- ctxInfo.MentionedJID = media.MentionedJIDs
- msg.VideoMessage = &waProto.VideoMessage{
- ContextInfo: ctxInfo,
- Caption: &media.Caption,
- JPEGThumbnail: media.Thumbnail,
- URL: &media.URL,
- DirectPath: &media.DirectPath,
- MediaKey: media.MediaKey,
- Mimetype: &content.GetInfo().MimeType,
- GifPlayback: &gifPlayback,
- Seconds: &duration,
- FileEncSHA256: media.FileEncSHA256,
- FileSHA256: media.FileSHA256,
- FileLength: proto.Uint64(uint64(media.FileLength)),
- }
- case event.MsgAudio:
- media, err := portal.preprocessMatrixMedia(ctx, sender, relaybotFormatted, content, evt.ID, whatsmeow.MediaAudio)
- if media == nil {
- return nil, sender, extraMeta, err
- }
- extraMeta.MediaHandle = media.Handle
- duration := uint32(content.GetInfo().Duration / 1000)
- msg.AudioMessage = &waProto.AudioMessage{
- ContextInfo: ctxInfo,
- URL: &media.URL,
- DirectPath: &media.DirectPath,
- MediaKey: media.MediaKey,
- Mimetype: &content.GetInfo().MimeType,
- Seconds: &duration,
- FileEncSHA256: media.FileEncSHA256,
- FileSHA256: media.FileSHA256,
- FileLength: proto.Uint64(uint64(media.FileLength)),
- }
- _, isMSC3245Voice := evt.Content.Raw["org.matrix.msc3245.voice"]
- if isMSC3245Voice {
- msg.AudioMessage.Waveform = getUnstableWaveform(evt.Content.Raw)
- msg.AudioMessage.PTT = proto.Bool(true)
- // hacky hack to add the codecs param that whatsapp seems to require
- msg.AudioMessage.Mimetype = proto.String(addCodecToMime(content.GetInfo().MimeType, "opus"))
- }
- case event.MsgFile:
- media, err := portal.preprocessMatrixMedia(ctx, sender, relaybotFormatted, content, evt.ID, whatsmeow.MediaDocument)
- if media == nil {
- return nil, sender, extraMeta, err
- }
- extraMeta.MediaHandle = media.Handle
- msg.DocumentMessage = &waProto.DocumentMessage{
- ContextInfo: ctxInfo,
- Caption: &media.Caption,
- JPEGThumbnail: media.Thumbnail,
- URL: &media.URL,
- DirectPath: &media.DirectPath,
- Title: &media.FileName,
- FileName: &media.FileName,
- MediaKey: media.MediaKey,
- Mimetype: &content.GetInfo().MimeType,
- FileEncSHA256: media.FileEncSHA256,
- FileSHA256: media.FileSHA256,
- FileLength: proto.Uint64(uint64(media.FileLength)),
- }
- if media.Caption != "" {
- msg.DocumentWithCaptionMessage = &waProto.FutureProofMessage{
- Message: &waProto.Message{
- DocumentMessage: msg.DocumentMessage,
- },
- }
- msg.DocumentMessage = nil
- }
- case event.MsgLocation:
- lat, long, err := parseGeoURI(content.GeoURI)
- if err != nil {
- return nil, sender, extraMeta, fmt.Errorf("%w: %v", errInvalidGeoURI, err)
- }
- msg.LocationMessage = &waProto.LocationMessage{
- DegreesLatitude: &lat,
- DegreesLongitude: &long,
- Comment: &content.Body,
- ContextInfo: ctxInfo,
- }
- default:
- return nil, sender, extraMeta, fmt.Errorf("%w %q", errUnknownMsgType, content.MsgType)
- }
-
- if editRootMsg != nil {
- msg = &waProto.Message{
- EditedMessage: &waProto.FutureProofMessage{
- Message: &waProto.Message{
- ProtocolMessage: &waProto.ProtocolMessage{
- Key: &waProto.MessageKey{
- FromMe: proto.Bool(true),
- ID: proto.String(editRootMsg.JID),
- RemoteJID: proto.String(portal.Key.JID.String()),
- },
- Type: waProto.ProtocolMessage_MESSAGE_EDIT.Enum(),
- EditedMessage: msg,
- TimestampMS: proto.Int64(evt.Timestamp),
- },
- },
- },
- }
- }
-
- return msg, sender, extraMeta, nil
-}
-
-func (portal *Portal) generateMessageInfo(sender *User) *types.MessageInfo {
- return &types.MessageInfo{
- ID: sender.Client.GenerateMessageID(),
- Timestamp: time.Now(),
- MessageSource: types.MessageSource{
- Sender: sender.JID,
- Chat: portal.Key.JID,
- IsFromMe: true,
- IsGroup: portal.Key.JID.Server == types.GroupServer || portal.Key.JID.Server == types.BroadcastServer,
- },
- }
-}
-
-func (portal *Portal) HandleMatrixMessage(ctx context.Context, sender *User, evt *event.Event, timings messageTimings) {
- start := time.Now()
- ms := metricSender{portal: portal, timings: &timings}
- log := zerolog.Ctx(ctx)
-
- allowRelay := evt.Type != TypeMSC3381PollResponse && evt.Type != TypeMSC3381V2PollResponse && evt.Type != TypeMSC3381PollStart
- if err := portal.canBridgeFrom(sender, allowRelay, true); err != nil {
- go ms.sendMessageMetrics(ctx, evt, err, "Ignoring", true)
- return
- } else if portal.Key.JID == types.StatusBroadcastJID && portal.bridge.Config.Bridge.DisableStatusBroadcastSend {
- go ms.sendMessageMetrics(ctx, evt, errBroadcastSendDisabled, "Ignoring", true)
- return
- }
-
- messageAge := timings.totalReceive
- origEvtID := evt.ID
- var dbMsg *database.Message
- if retryMeta := evt.Content.AsMessage().MessageSendRetry; retryMeta != nil {
- origEvtID = retryMeta.OriginalEventID
- var err error
- logEvt := log.Debug().
- Dur("message_age", messageAge).
- Int("retry_count", retryMeta.RetryCount).
- Stringer("orig_event_id", origEvtID)
- dbMsg, err = portal.bridge.DB.Message.GetByMXID(ctx, origEvtID)
- if err != nil {
- log.Err(err).Msg("Failed to get retry request target message from database")
- // TODO drop message?
- } else if dbMsg != nil && dbMsg.Sent {
- logEvt.
- Str("wa_message_id", dbMsg.JID).
- Msg("Ignoring retry request as message was already sent")
- go ms.sendMessageMetrics(ctx, evt, nil, "", true)
- return
- } else if dbMsg != nil {
- logEvt.
- Str("wa_message_id", dbMsg.JID).
- Msg("Got retry request for message")
- } else {
- logEvt.Msg("Got retry request for message, but original message is not known")
- }
- } else {
- log.Debug().Dur("message_age", messageAge).Msg("Received Matrix message")
- }
-
- errorAfter := portal.bridge.Config.Bridge.MessageHandlingTimeout.ErrorAfter
- deadline := portal.bridge.Config.Bridge.MessageHandlingTimeout.Deadline
- isScheduled, _ := evt.Content.Raw["com.beeper.scheduled"].(bool)
- if isScheduled {
- log.Debug().Msg("Message is a scheduled message, extending handling timeouts")
- errorAfter *= 10
- deadline *= 10
- }
-
- if errorAfter > 0 {
- remainingTime := errorAfter - messageAge
- if remainingTime < 0 {
- go ms.sendMessageMetrics(ctx, evt, errTimeoutBeforeHandling, "Timeout handling", true)
- return
- } else if remainingTime < 1*time.Second {
- log.Warn().
- Dur("remaining_timeout", remainingTime).
- Dur("warning_total_timeout", errorAfter).
- Msg("Message was delayed before reaching the bridge")
- }
- go func() {
- time.Sleep(remainingTime)
- ms.sendMessageMetrics(ctx, evt, errMessageTakingLong, "Timeout handling", false)
- }()
- }
-
- timedCtx := ctx
- if deadline > 0 {
- var cancel context.CancelFunc
- timedCtx, cancel = context.WithTimeout(ctx, deadline)
- defer cancel()
- }
-
- timings.preproc = time.Since(start)
- start = time.Now()
- msg, sender, extraMeta, err := portal.convertMatrixMessage(timedCtx, sender, evt)
- timings.convert = time.Since(start)
- if msg == nil {
- go ms.sendMessageMetrics(ctx, evt, err, "Error converting", true)
- return
- }
- if extraMeta == nil {
- extraMeta = &extraConvertMeta{}
- }
- dbMsgType := database.MsgNormal
- if msg.PollCreationMessage != nil || msg.PollCreationMessageV2 != nil || msg.PollCreationMessageV3 != nil {
- dbMsgType = database.MsgMatrixPoll
- } else if msg.EditedMessage == nil {
- portal.MarkDisappearing(ctx, origEvtID, time.Duration(portal.ExpirationTime)*time.Second, time.Now())
- } else {
- dbMsgType = database.MsgEdit
- }
- info := portal.generateMessageInfo(sender)
- if dbMsg == nil {
- dbMsg = portal.markHandled(ctx, nil, info, evt.ID, evt.Sender, false, true, dbMsgType, 0, database.MsgNoError)
- } else {
- info.ID = dbMsg.JID
- }
- log.UpdateContext(func(c zerolog.Context) zerolog.Context {
- return c.Str("wa_message_id", info.ID)
- })
- if dbMsgType == database.MsgMatrixPoll && extraMeta.PollOptions != nil {
- err = dbMsg.PutPollOptions(ctx, extraMeta.PollOptions)
- if err != nil {
- log.Err(err).Msg("Failed to save poll options in message to database")
- }
- }
- log.Debug().Msg("Sending Matrix event to WhatsApp")
- start = time.Now()
- resp, err := sender.Client.SendMessage(timedCtx, portal.Key.JID, msg, whatsmeow.SendRequestExtra{
- ID: info.ID,
- MediaHandle: extraMeta.MediaHandle,
- })
- timings.totalSend = time.Since(start)
- timings.whatsmeow = resp.DebugTimings
- if err != nil {
- go ms.sendMessageMetrics(ctx, evt, err, "Error sending", true)
- return
- }
- err = dbMsg.MarkSent(ctx, resp.Timestamp)
- if err != nil {
- log.Err(err).Msg("Failed to mark message as sent in database")
- }
- if extraMeta != nil && len(extraMeta.GalleryExtraParts) > 0 {
- for i, part := range extraMeta.GalleryExtraParts {
- partInfo := portal.generateMessageInfo(sender)
- partDBMsg := portal.markHandled(ctx, nil, partInfo, evt.ID, evt.Sender, false, true, database.MsgBeeperGallery, i+1, database.MsgNoError)
- log.Debug().Int("part_index", i+1).Str("wa_part_message_id", partInfo.ID).Msg("Sending gallery part to WhatsApp")
- resp, err = sender.Client.SendMessage(timedCtx, portal.Key.JID, part, whatsmeow.SendRequestExtra{ID: partInfo.ID})
- if err != nil {
- go ms.sendMessageMetrics(ctx, evt, err, "Error sending", true)
- return
- }
- log.Debug().Int("part_index", i+1).Str("wa_part_message_id", partInfo.ID).Msg("Sent gallery part to WhatsApp")
- err = partDBMsg.MarkSent(ctx, resp.Timestamp)
- if err != nil {
- log.Err(err).
- Str("part_id", partInfo.ID).
- Msg("Failed to mark gallery extra part as sent in database")
- }
- }
- }
- go ms.sendMessageMetrics(ctx, evt, nil, "", true)
-}
-
-func (portal *Portal) HandleMatrixReaction(ctx context.Context, sender *User, evt *event.Event) {
- log := zerolog.Ctx(ctx)
- if err := portal.canBridgeFrom(sender, false, true); err != nil {
- go portal.sendMessageMetrics(ctx, evt, err, "Ignoring", nil)
- return
- } else if portal.Key.JID.Server == types.BroadcastServer {
- // TODO implement this, probably by only sending the reaction to the sender of the status message?
- // (whatsapp hasn't published the feature yet)
- go portal.sendMessageMetrics(ctx, evt, errBroadcastReactionNotSupported, "Ignoring", nil)
- return
- }
-
- content, ok := evt.Content.Parsed.(*event.ReactionEventContent)
- if ok && strings.Contains(content.RelatesTo.Key, "retry") || strings.HasPrefix(content.RelatesTo.Key, "\u267b") { // ♻️
- if retryRequested, _ := portal.requestMediaRetry(ctx, sender, content.RelatesTo.EventID, nil); retryRequested {
- _, _ = portal.MainIntent().RedactEvent(ctx, portal.MXID, evt.ID, mautrix.ReqRedact{
- Reason: "requested media from phone",
- })
- // Errored media, don't try to send as reaction
- return
- }
- }
-
- log.Debug().Msg("Received Matrix reaction event")
- err := portal.handleMatrixReaction(ctx, sender, evt)
- go portal.sendMessageMetrics(ctx, evt, err, "Error sending", nil)
-}
-
-func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *User, evt *event.Event) error {
- log := zerolog.Ctx(ctx)
- content, ok := evt.Content.Parsed.(*event.ReactionEventContent)
- if !ok {
- return fmt.Errorf("unexpected parsed content type %T", evt.Content.Parsed)
- }
- log.UpdateContext(func(c zerolog.Context) zerolog.Context {
- return c.Stringer("target_event_id", content.RelatesTo.EventID)
- })
- target, err := portal.bridge.DB.Message.GetByMXID(ctx, content.RelatesTo.EventID)
- if err != nil {
- log.Err(err).Msg("Failed to get target message from database")
- return fmt.Errorf("failed to get target event")
- } else if target == nil || target.Type == database.MsgReaction {
- return fmt.Errorf("unknown target event %s", content.RelatesTo.EventID)
- }
- info := portal.generateMessageInfo(sender)
- dbMsg := portal.markHandled(ctx, nil, info, evt.ID, evt.Sender, false, true, database.MsgReaction, 0, database.MsgNoError)
- portal.upsertReaction(ctx, nil, target.JID, sender.JID, evt.ID, info.ID)
- log.Debug().Str("whatsapp_reaction_id", info.ID).Msg("Sending Matrix reaction to WhatsApp")
- resp, err := portal.sendReactionToWhatsApp(sender, info.ID, target, content.RelatesTo.Key, evt.Timestamp)
- if err == nil {
- err = dbMsg.MarkSent(ctx, resp.Timestamp)
- }
- return err
-}
-
-func (portal *Portal) sendReactionToWhatsApp(sender *User, id types.MessageID, target *database.Message, key string, timestamp int64) (whatsmeow.SendResponse, error) {
- var messageKeyParticipant *string
- if !portal.IsPrivateChat() {
- messageKeyParticipant = proto.String(target.Sender.ToNonAD().String())
- }
- key = variationselector.Remove(key)
- ctx, cancel := context.WithTimeout(context.TODO(), 60*time.Second)
- defer cancel()
- return sender.Client.SendMessage(ctx, portal.Key.JID, &waProto.Message{
- ReactionMessage: &waProto.ReactionMessage{
- Key: &waProto.MessageKey{
- RemoteJID: proto.String(portal.Key.JID.String()),
- FromMe: proto.Bool(target.Sender.User == sender.JID.User),
- ID: proto.String(target.JID),
- Participant: messageKeyParticipant,
- },
- Text: proto.String(key),
- SenderTimestampMS: proto.Int64(timestamp),
- },
- }, whatsmeow.SendRequestExtra{ID: id})
-}
-
-func (portal *Portal) upsertReaction(ctx context.Context, intent *appservice.IntentAPI, targetJID types.MessageID, senderJID types.JID, mxid id.EventID, jid types.MessageID) {
- log := zerolog.Ctx(ctx)
- dbReaction, err := portal.bridge.DB.Reaction.GetByTargetJID(ctx, portal.Key, targetJID, senderJID)
- if err != nil {
- log.Err(err).Msg("Failed to get existing reaction from database for upsert")
- return
- }
- if dbReaction == nil {
- dbReaction = portal.bridge.DB.Reaction.New()
- dbReaction.Chat = portal.Key
- dbReaction.TargetJID = targetJID
- dbReaction.Sender = senderJID
- } else if intent != nil {
- log.Debug().
- Stringer("old_reaction_mxid", dbReaction.MXID).
- Msg("Redacting old Matrix reaction after new one was sent")
- if intent != nil {
- _, err = intent.RedactEvent(ctx, portal.MXID, dbReaction.MXID)
- }
- if intent == nil || errors.Is(err, mautrix.MForbidden) {
- _, err = portal.MainIntent().RedactEvent(ctx, portal.MXID, dbReaction.MXID)
- }
- if err != nil {
- log.Err(err).
- Stringer("old_reaction_mxid", dbReaction.MXID).
- Msg("Failed to redact old reaction")
- }
- }
- dbReaction.MXID = mxid
- dbReaction.JID = jid
- err = dbReaction.Upsert(ctx)
- if err != nil {
- log.Err(err).Msg("Failed to upsert reaction to database")
- }
-}
-
-func (portal *Portal) HandleMatrixRedaction(ctx context.Context, sender *User, evt *event.Event) {
- log := zerolog.Ctx(ctx)
- if err := portal.canBridgeFrom(sender, true, true); err != nil {
- go portal.sendMessageMetrics(ctx, evt, err, "Ignoring", nil)
- return
- }
- log.Debug().Msg("Received Matrix redaction")
-
- senderLogIdentifier := sender.MXID
- if !sender.HasSession() {
- sender = portal.GetRelayUser()
- senderLogIdentifier += " (through relaybot)"
- }
-
- msg, err := portal.bridge.DB.Message.GetByMXID(ctx, evt.Redacts)
- if err != nil {
- log.Err(err).Msg("Failed to get redaction target event from database")
- go portal.sendMessageMetrics(ctx, evt, errTargetNotFound, "Ignoring", nil)
- } else if msg == nil {
- go portal.sendMessageMetrics(ctx, evt, errTargetNotFound, "Ignoring", nil)
- } else if msg.IsFakeJID() {
- go portal.sendMessageMetrics(ctx, evt, errTargetIsFake, "Ignoring", nil)
- } else if portal.Key.JID == types.StatusBroadcastJID && portal.bridge.Config.Bridge.DisableStatusBroadcastSend {
- go portal.sendMessageMetrics(ctx, evt, errBroadcastSendDisabled, "Ignoring", nil)
- } else if msg.Type == database.MsgReaction {
- if msg.Sender.User != sender.JID.User {
- go portal.sendMessageMetrics(ctx, evt, errReactionSentBySomeoneElse, "Ignoring", nil)
- } else if reaction, err := portal.bridge.DB.Reaction.GetByMXID(ctx, evt.Redacts); err != nil {
- log.Err(err).Msg("Failed to get target reaction from database")
- go portal.sendMessageMetrics(ctx, evt, errReactionDatabaseNotFound, "Ignoring", nil)
- } else if reaction == nil {
- go portal.sendMessageMetrics(ctx, evt, errReactionDatabaseNotFound, "Ignoring", nil)
- } else if reactionTarget, err := portal.bridge.DB.Message.GetByJID(ctx, reaction.Chat, reaction.TargetJID); err != nil {
- log.Err(err).Msg("Failed to get target reaction's target message from database")
- go portal.sendMessageMetrics(ctx, evt, errReactionTargetNotFound, "Ignoring", nil)
- } else if reactionTarget == nil {
- go portal.sendMessageMetrics(ctx, evt, errReactionTargetNotFound, "Ignoring", nil)
- } else {
- log.Debug().Str("reaction_target_message_id", msg.JID).Msg("Sending redaction of reaction to WhatsApp")
- _, err = portal.sendReactionToWhatsApp(sender, "", reactionTarget, "", evt.Timestamp)
- go portal.sendMessageMetrics(ctx, evt, err, "Error sending", nil)
- }
- } else {
- key := &waProto.MessageKey{
- FromMe: proto.Bool(true),
- ID: proto.String(msg.JID),
- RemoteJID: proto.String(portal.Key.JID.String()),
- }
- if msg.Sender.User != sender.JID.User {
- if portal.IsPrivateChat() {
- go portal.sendMessageMetrics(ctx, evt, errDMSentByOtherUser, "Ignoring", nil)
- return
- }
- key.FromMe = proto.Bool(false)
- key.Participant = proto.String(msg.Sender.ToNonAD().String())
- }
- log.Debug().Str("target_message_id", msg.JID).Msg("Sending redaction of message to WhatsApp")
- timedCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
- defer cancel()
- _, err = sender.Client.SendMessage(timedCtx, portal.Key.JID, &waProto.Message{
- ProtocolMessage: &waProto.ProtocolMessage{
- Type: waProto.ProtocolMessage_REVOKE.Enum(),
- Key: key,
- },
- })
- go portal.sendMessageMetrics(ctx, evt, err, "Error sending", nil)
- }
-}
-
-func (portal *Portal) HandleMatrixReadReceipt(sender bridge.User, eventID id.EventID, receipt event.ReadReceipt) {
- log := portal.zlog.With().
- Str("action", "handle matrix read receipt").
- Stringer("event_id", eventID).
- Stringer("user_id", sender.GetMXID()).
- Logger()
- ctx := log.WithContext(context.TODO())
- portal.handleMatrixReadReceipt(ctx, sender.(*User), eventID, receipt.Timestamp, true)
-}
-
-func (portal *Portal) handleMatrixReadReceipt(ctx context.Context, sender *User, eventID id.EventID, receiptTimestamp time.Time, isExplicit bool) {
- log := zerolog.Ctx(ctx).With().
- Stringer("sender_jid", sender.JID).
- Logger()
- if !sender.IsLoggedIn() {
- if isExplicit {
- log.Debug().Msg("Ignoring read receipt: user is not connected to WhatsApp")
- }
- return
- }
-
- maxTimestamp := receiptTimestamp
- // Implicit read receipts don't have an event ID that's already bridged
- if isExplicit {
- if message, err := portal.bridge.DB.Message.GetByMXID(ctx, eventID); err != nil {
- log.Err(err).Msg("Failed to get read receipt target message")
- } else if message != nil {
- maxTimestamp = message.Timestamp
- }
- }
-
- prevTimestamp := sender.GetLastReadTS(ctx, portal.Key)
- lastReadIsZero := false
- if prevTimestamp.IsZero() {
- prevTimestamp = maxTimestamp.Add(-2 * time.Second)
- lastReadIsZero = true
- }
-
- messages, err := portal.bridge.DB.Message.GetMessagesBetween(ctx, portal.Key, prevTimestamp, maxTimestamp)
- if err != nil {
- log.Err(err).Msg("Failed to get messages that need receipts")
- return
- }
- if len(messages) > 0 {
- sender.SetLastReadTS(ctx, portal.Key, messages[len(messages)-1].Timestamp)
- }
- groupedMessages := make(map[types.JID][]types.MessageID)
- for _, msg := range messages {
- var key types.JID
- if msg.IsFakeJID() || msg.Sender.User == sender.JID.User {
- // Don't send read receipts for own messages or fake messages
- continue
- } else if !portal.IsPrivateChat() {
- key = msg.Sender
- } else if !msg.BroadcastListJID.IsEmpty() {
- key = msg.BroadcastListJID
- } // else: blank key (participant field isn't needed in direct chat read receipts)
- groupedMessages[key] = append(groupedMessages[key], msg.JID)
- }
- // For explicit read receipts, log even if there are no targets. For implicit ones only log when there are targets
- if len(groupedMessages) > 0 || isExplicit {
- log.Debug().
- Bool("explicit", isExplicit).
- Time("last_read", prevTimestamp).
- Bool("last_read_is_zero", lastReadIsZero).
- Any("receipts", groupedMessages).
- Msg("Sending read receipts to WhatsApp")
- }
- for messageSender, ids := range groupedMessages {
- chatJID := portal.Key.JID
- if messageSender.Server == types.BroadcastServer {
- chatJID = messageSender
- messageSender = portal.Key.JID
- }
- err = sender.Client.MarkRead(ids, receiptTimestamp, chatJID, messageSender)
- if err != nil {
- log.Err(err).
- Array("message_ids", exzerolog.ArrayOfStrs(ids)).
- Stringer("target_user_jid", messageSender).
- Msg("Failed to send read receipt")
- }
- }
-}
-
-func typingDiff(prev, new []id.UserID) (started, stopped []id.UserID) {
-OuterNew:
- for _, userID := range new {
- for _, previousUserID := range prev {
- if userID == previousUserID {
- continue OuterNew
- }
- }
- started = append(started, userID)
- }
-OuterPrev:
- for _, userID := range prev {
- for _, previousUserID := range new {
- if userID == previousUserID {
- continue OuterPrev
- }
- }
- stopped = append(stopped, userID)
- }
- return
-}
-
-func (portal *Portal) setTyping(userIDs []id.UserID, state types.ChatPresence) {
- for _, userID := range userIDs {
- user := portal.bridge.GetUserByMXIDIfExists(userID)
- if user == nil || !user.IsLoggedIn() {
- continue
- }
- portal.zlog.Debug().
- Stringer("user_jid", user.JID).
- Stringer("user_mxid", user.MXID).
- Str("state", string(state)).
- Msg("Bridging typing change to chat presence")
- err := user.Client.SendChatPresence(portal.Key.JID, state, types.ChatPresenceMediaText)
- if err != nil {
- portal.zlog.Err(err).
- Stringer("user_jid", user.JID).
- Stringer("user_mxid", user.MXID).
- Str("state", string(state)).
- Msg("Failed to send chat presence")
- }
- if portal.bridge.Config.Bridge.SendPresenceOnTyping {
- err = user.Client.SendPresence(types.PresenceAvailable)
- if err != nil {
- user.zlog.Warn().Err(err).Msg("Failed to set presence on typing")
- }
- }
- }
-}
-
-func (portal *Portal) HandleMatrixTyping(newTyping []id.UserID) {
- portal.currentlyTypingLock.Lock()
- defer portal.currentlyTypingLock.Unlock()
- startedTyping, stoppedTyping := typingDiff(portal.currentlyTyping, newTyping)
- portal.currentlyTyping = newTyping
- portal.setTyping(startedTyping, types.ChatPresenceComposing)
- portal.setTyping(stoppedTyping, types.ChatPresencePaused)
-}
-
-func (portal *Portal) canBridgeFrom(sender *User, allowRelay, reconnectWait bool) error {
- if !sender.IsLoggedIn() {
- if allowRelay && portal.HasRelaybot() {
- return nil
- } else if sender.Session != nil {
- return errUserNotConnected
- } else if reconnectWait {
- // If a message was received exactly during a disconnection, wait a second for the socket to reconnect
- time.Sleep(1 * time.Second)
- return portal.canBridgeFrom(sender, allowRelay, false)
- } else {
- return errUserNotLoggedIn
- }
- } else if portal.IsPrivateChat() && sender.JID.User != portal.Key.Receiver.User && (!allowRelay || !portal.HasRelaybot()) {
- return errDifferentUser
- }
- return nil
-}
-
-func (portal *Portal) resetChildSpaceStatus() {
- for _, childPortal := range portal.bridge.portalsByJID {
- if childPortal.ParentGroup == portal.Key.JID {
- if portal.MXID != "" && childPortal.InSpace {
- go childPortal.removeSpaceParentEvent(portal.MXID)
- }
- childPortal.InSpace = false
- if childPortal.parentPortal == portal {
- childPortal.parentPortal = nil
- }
- }
- }
-}
-
-func (portal *Portal) Delete(ctx context.Context) {
- err := portal.Portal.Delete(ctx)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to delete portal from database")
- }
- portal.bridge.portalsLock.Lock()
- delete(portal.bridge.portalsByJID, portal.Key)
- if len(portal.MXID) > 0 {
- delete(portal.bridge.portalsByMXID, portal.MXID)
- }
- portal.resetChildSpaceStatus()
- portal.bridge.portalsLock.Unlock()
-}
-
-func (portal *Portal) GetMatrixUsers(ctx context.Context) ([]id.UserID, error) {
- members, err := portal.MainIntent().JoinedMembers(ctx, portal.MXID)
- if err != nil {
- return nil, fmt.Errorf("failed to get member list: %w", err)
- }
- var users []id.UserID
- for userID := range members.Joined {
- _, isPuppet := portal.bridge.ParsePuppetMXID(userID)
- if !isPuppet && userID != portal.bridge.Bot.UserID {
- users = append(users, userID)
- }
- }
- return users, nil
-}
-
-func (portal *Portal) CleanupIfEmpty(ctx context.Context) {
- users, err := portal.GetMatrixUsers(ctx)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to get Matrix user list to determine if portal needs to be cleaned up")
- return
- }
-
- if len(users) == 0 {
- zerolog.Ctx(ctx).Info().Msg("Room seems to be empty, cleaning up...")
- portal.Delete(ctx)
- portal.Cleanup(ctx, false)
- }
-}
-
-func (portal *Portal) Cleanup(ctx context.Context, puppetsOnly bool) {
- if len(portal.MXID) == 0 {
- return
- }
- log := zerolog.Ctx(ctx)
- intent := portal.MainIntent()
- if portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) {
- err := intent.BeeperDeleteRoom(ctx, portal.MXID)
- if err == nil || errors.Is(err, mautrix.MNotFound) {
- return
- }
- log.Warn().Err(err).Msg("Failed to delete room using beeper yeet endpoint, falling back to normal behavior")
- }
- members, err := intent.JoinedMembers(ctx, portal.MXID)
- if err != nil {
- log.Err(err).Msg("Failed to get portal members for cleanup")
- return
- }
- for member := range members.Joined {
- if member == intent.UserID {
- continue
- }
- puppet := portal.bridge.GetPuppetByMXID(member)
- if puppet != nil {
- _, err = puppet.DefaultIntent().LeaveRoom(ctx, portal.MXID)
- if err != nil {
- log.Err(err).Stringer("puppet_mxid", puppet.MXID).Msg("Failed to leave room as puppet while cleaning up portal")
- }
- } else if !puppetsOnly {
- _, err = intent.KickUser(ctx, portal.MXID, &mautrix.ReqKickUser{UserID: member, Reason: "Deleting portal"})
- if err != nil {
- log.Err(err).Stringer("user_mxid", member).Msg("Failed to kick user while cleaning up portal")
- }
- }
- }
- _, err = intent.LeaveRoom(ctx, portal.MXID)
- if err != nil {
- log.Err(err).Msg("Failed to leave room with main intent while cleaning up portal")
- }
-}
-
-func (portal *Portal) HandleMatrixLeave(brSender bridge.User, evt *event.Event) {
- log := portal.zlog.With().
- Str("action", "handle matrix leave").
- Stringer("event_id", evt.ID).
- Stringer("user_id", brSender.GetMXID()).
- Logger()
- ctx := log.WithContext(context.TODO())
- sender := brSender.(*User)
- if portal.IsPrivateChat() {
- log.Debug().Msg("User left private chat portal, cleaning up and deleting...")
- portal.Delete(ctx)
- portal.Cleanup(ctx, false)
- return
- } else if portal.bridge.Config.Bridge.BridgeMatrixLeave {
- err := sender.Client.LeaveGroup(portal.Key.JID)
- if err != nil {
- log.Err(err).Msg("Failed to leave group")
- return
- }
- //portal.log.Infoln("Leave response:", <-resp)
- }
- portal.CleanupIfEmpty(ctx)
-}
-
-func (portal *Portal) HandleMatrixKick(brSender bridge.User, brTarget bridge.Ghost, evt *event.Event) {
- sender := brSender.(*User)
- target := brTarget.(*Puppet)
- _, err := sender.Client.UpdateGroupParticipants(portal.Key.JID, []types.JID{target.JID}, whatsmeow.ParticipantChangeRemove)
- if err != nil {
- portal.zlog.Err(err).
- Stringer("kicked_by_mxid", sender.MXID).
- Stringer("kicked_by_jid", sender.JID).
- Stringer("target_jid", target.JID).
- Msg("Failed to kick user from group")
- return
- }
- //portal.log.Infoln("Kick %s response: %s", puppet.JID, <-resp)
-}
-
-func (portal *Portal) HandleMatrixInvite(brSender bridge.User, brTarget bridge.Ghost, evt *event.Event) {
- sender := brSender.(*User)
- target := brTarget.(*Puppet)
- _, err := sender.Client.UpdateGroupParticipants(portal.Key.JID, []types.JID{target.JID}, whatsmeow.ParticipantChangeAdd)
- if err != nil {
- portal.zlog.Err(err).
- Stringer("inviter_mxid", sender.MXID).
- Stringer("inviter_jid", sender.JID).
- Stringer("target_jid", target.JID).
- Msg("Failed to add user to group")
- return
- }
- //portal.log.Infofln("Add %s response: %s", puppet.JID, <-resp)
-}
-
-func (portal *Portal) HandleMatrixMeta(brSender bridge.User, evt *event.Event) {
- sender := brSender.(*User)
- if !sender.Whitelisted || !sender.IsLoggedIn() {
- return
- }
- log := portal.zlog.With().
- Str("action", "handle matrix metadata").
- Str("event_type", evt.Type.Type).
- Stringer("event_id", evt.ID).
- Stringer("sender", sender.MXID).
- Logger()
- ctx := log.WithContext(context.TODO())
-
- switch content := evt.Content.Parsed.(type) {
- case *event.RoomNameEventContent:
- if content.Name == portal.Name {
- return
- }
- portal.Name = content.Name
- err := sender.Client.SetGroupName(portal.Key.JID, content.Name)
- if err != nil {
- log.Err(err).Msg("Failed to update group name")
- return
- }
- case *event.TopicEventContent:
- if content.Topic == portal.Topic {
- return
- }
- portal.Topic = content.Topic
- err := sender.Client.SetGroupTopic(portal.Key.JID, "", "", content.Topic)
- if err != nil {
- log.Err(err).Msg("Failed to update group topic")
- return
- }
- case *event.RoomAvatarEventContent:
- portal.avatarLock.Lock()
- defer portal.avatarLock.Unlock()
- url := content.URL.ParseOrIgnore()
- if url == portal.AvatarURL || (url.IsEmpty() && portal.Avatar == "remove") {
- return
- }
- var data []byte
- var err error
- if !url.IsEmpty() {
- data, err = portal.MainIntent().DownloadBytes(ctx, url)
- if err != nil {
- log.Err(err).Stringer("mxc_uri", url).Msg("Failed to download updated avatar")
- return
- }
- log.Debug().Stringer("mxc_uri", url).Msg("Updating group avatar")
- } else {
- log.Debug().Msg("Removing group avatar")
- }
- newID, err := sender.Client.SetGroupPhoto(portal.Key.JID, data)
- if err != nil {
- log.Err(err).Msg("Failed to update group avatar")
- return
- }
- log.Debug().Str("avatar_id", newID).Msg("Successfully updated group avatar")
- portal.Avatar = newID
- portal.AvatarURL = url
- default:
- log.Debug().Type("content_type", content).Msg("Ignoring unknown metadata event type")
- return
- }
- portal.UpdateBridgeInfo(ctx)
- err := portal.Update(ctx)
- if err != nil {
- log.Err(err).Msg("Failed to update portal after handling metadata")
- }
-}
diff --git a/provisioning.go b/provisioning.go
deleted file mode 100644
index 22e43ff..0000000
--- a/provisioning.go
+++ /dev/null
@@ -1,808 +0,0 @@
-// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
-// Copyright (C) 2022 Tulir Asokan
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package main
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- _ "net/http/pprof"
- "regexp"
- "strings"
- "time"
-
- "github.com/gorilla/mux"
- "github.com/gorilla/websocket"
- "github.com/rs/zerolog"
- "github.com/rs/zerolog/hlog"
- "go.mau.fi/util/requestlog"
- "go.mau.fi/whatsmeow"
- "go.mau.fi/whatsmeow/appstate"
- "go.mau.fi/whatsmeow/types"
- "maunium.net/go/mautrix/bridge/status"
- "maunium.net/go/mautrix/id"
-)
-
-type ProvisioningAPI struct {
- bridge *WABridge
- log zerolog.Logger
-}
-
-func (prov *ProvisioningAPI) Init() {
- prov.log.Debug().Str("base_path", prov.bridge.Config.Bridge.Provisioning.Prefix).Msg("Enabling provisioning API")
- r := prov.bridge.AS.Router.PathPrefix(prov.bridge.Config.Bridge.Provisioning.Prefix).Subrouter()
- r.Use(hlog.NewHandler(prov.log))
- r.Use(requestlog.AccessLogger(true))
- r.Use(prov.AuthMiddleware)
- r.HandleFunc("/v1/ping", prov.Ping).Methods(http.MethodGet)
- r.HandleFunc("/v1/login", prov.Login).Methods(http.MethodGet)
- r.HandleFunc("/v1/logout", prov.Logout).Methods(http.MethodPost)
- r.HandleFunc("/v1/delete_session", prov.DeleteSession).Methods(http.MethodPost)
- r.HandleFunc("/v1/disconnect", prov.Disconnect).Methods(http.MethodPost)
- r.HandleFunc("/v1/reconnect", prov.Reconnect).Methods(http.MethodPost)
- r.HandleFunc("/v1/debug/appstate/{name}", prov.SyncAppState).Methods(http.MethodPost)
- r.HandleFunc("/v1/contacts", prov.ListContacts).Methods(http.MethodGet)
- r.HandleFunc("/v1/groups", prov.ListGroups).Methods(http.MethodGet, http.MethodPost)
- r.HandleFunc("/v1/resolve_identifier/{number}", prov.ResolveIdentifier).Methods(http.MethodGet)
- r.HandleFunc("/v1/bulk_resolve_identifier", prov.BulkResolveIdentifier).Methods(http.MethodPost)
- r.HandleFunc("/v1/pm/{number}", prov.StartPM).Methods(http.MethodPost)
- r.HandleFunc("/v1/open/{groupID}", prov.OpenGroup).Methods(http.MethodPost)
- r.HandleFunc("/v1/group/open/{groupID}", prov.OpenGroup).Methods(http.MethodPost)
- r.HandleFunc("/v1/group/resolve/{inviteCode}", prov.ResolveGroupInvite).Methods(http.MethodPost)
- r.HandleFunc("/v1/group/join/{inviteCode}", prov.JoinGroup).Methods(http.MethodPost)
- prov.bridge.AS.Router.HandleFunc("/_matrix/app/com.beeper.asmux/ping", prov.BridgeStatePing).Methods(http.MethodPost)
- prov.bridge.AS.Router.HandleFunc("/_matrix/app/com.beeper.bridge_state", prov.BridgeStatePing).Methods(http.MethodPost)
-
- if prov.bridge.Config.Bridge.Provisioning.DebugEndpoints {
- prov.log.Debug().Msg("Enabling debug API at /debug")
- r := prov.bridge.AS.Router.PathPrefix("/debug").Subrouter()
- r.Use(prov.AuthMiddleware)
- r.PathPrefix("/pprof").Handler(http.DefaultServeMux)
- }
-
- // Deprecated, just use /disconnect
- r.HandleFunc("/v1/delete_connection", prov.Disconnect).Methods(http.MethodPost)
-}
-
-func (prov *ProvisioningAPI) AuthMiddleware(h http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- auth := r.Header.Get("Authorization")
- if len(auth) == 0 && strings.HasSuffix(r.URL.Path, "/login") {
- authParts := strings.Split(r.Header.Get("Sec-WebSocket-Protocol"), ",")
- for _, part := range authParts {
- part = strings.TrimSpace(part)
- if strings.HasPrefix(part, "net.maunium.whatsapp.auth-") {
- auth = part[len("net.maunium.whatsapp.auth-"):]
- break
- }
- }
- } else if strings.HasPrefix(auth, "Bearer ") {
- auth = auth[len("Bearer "):]
- }
- if auth != prov.bridge.Config.Bridge.Provisioning.SharedSecret {
- hlog.FromRequest(r).Debug().Msg("Authentication token does not match shared secret")
- jsonResponse(w, http.StatusForbidden, map[string]interface{}{
- "error": "Authentication token does not match shared secret",
- "errcode": "M_FORBIDDEN",
- })
- return
- }
- userID := r.URL.Query().Get("user_id")
- user := prov.bridge.GetUserByMXID(id.UserID(userID))
- if user != nil {
- hlog.FromRequest(r).UpdateContext(func(c zerolog.Context) zerolog.Context {
- return c.Stringer("user_id", user.MXID)
- })
- }
- h.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), "user", user)))
- })
-}
-
-type Error struct {
- Success bool `json:"success"`
- Error string `json:"error"`
- ErrCode string `json:"errcode"`
-}
-
-type Response struct {
- Success bool `json:"success"`
- Status string `json:"status"`
-}
-
-func (prov *ProvisioningAPI) DeleteSession(w http.ResponseWriter, r *http.Request) {
- user := r.Context().Value("user").(*User)
- if user.Session == nil && user.Client == nil {
- jsonResponse(w, http.StatusNotFound, Error{
- Error: "Nothing to purge: no session information stored and no active connection.",
- ErrCode: "no session",
- })
- return
- }
- user.DeleteConnection()
- user.DeleteSession(r.Context())
- jsonResponse(w, http.StatusOK, Response{true, "Session information purged"})
- user.removeFromJIDMap(status.BridgeState{StateEvent: status.StateLoggedOut})
-}
-
-func (prov *ProvisioningAPI) Disconnect(w http.ResponseWriter, r *http.Request) {
- user := r.Context().Value("user").(*User)
- if user.Client == nil {
- jsonResponse(w, http.StatusNotFound, Error{
- Error: "You don't have a WhatsApp connection.",
- ErrCode: "no connection",
- })
- return
- }
- user.DeleteConnection()
- jsonResponse(w, http.StatusOK, Response{true, "Disconnected from WhatsApp"})
- user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Error: WANotConnected})
-}
-
-func (prov *ProvisioningAPI) Reconnect(w http.ResponseWriter, r *http.Request) {
- user := r.Context().Value("user").(*User)
- if user.Client == nil {
- if user.Session == nil {
- jsonResponse(w, http.StatusForbidden, Error{
- Error: "No existing connection and no session. Please log in first.",
- ErrCode: "no session",
- })
- } else {
- user.Connect()
- jsonResponse(w, http.StatusAccepted, Response{true, "Created connection to WhatsApp."})
- }
- } else {
- user.DeleteConnection()
- user.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: WANotConnected})
- user.Connect()
- jsonResponse(w, http.StatusAccepted, Response{true, "Restarted connection to WhatsApp"})
- }
-}
-
-func (prov *ProvisioningAPI) SyncAppState(w http.ResponseWriter, r *http.Request) {
- user := r.Context().Value("user").(*User)
- if user == nil || user.Client == nil {
- jsonResponse(w, http.StatusNotFound, Error{
- Error: "User is not connected to WhatsApp",
- ErrCode: "no session",
- })
- return
- }
-
- vars := mux.Vars(r)
- nameStr := vars["name"]
- if len(nameStr) == 0 {
- jsonResponse(w, http.StatusBadRequest, Error{
- Error: "The `name` parameter is required",
- ErrCode: "missing-name-param",
- })
- return
- }
- var name appstate.WAPatchName
- for _, existingName := range appstate.AllPatchNames {
- if nameStr == string(existingName) {
- name = existingName
- }
- }
- if len(name) == 0 {
- jsonResponse(w, http.StatusBadRequest, Error{
- Error: fmt.Sprintf("'%s' is not a valid app state patch name", nameStr),
- ErrCode: "invalid-name-param",
- })
- return
- }
- fullStr := r.URL.Query().Get("full")
- fullSync := len(fullStr) > 0 && (fullStr == "1" || strings.ToLower(fullStr)[0] == 't')
- err := user.Client.FetchAppState(name, fullSync, false)
- if err != nil {
- jsonResponse(w, http.StatusInternalServerError, Error{false, err.Error(), "sync-fail"})
- } else {
- jsonResponse(w, http.StatusOK, Response{true, fmt.Sprintf("Synced app state %s", name)})
- }
-}
-
-func (prov *ProvisioningAPI) ListContacts(w http.ResponseWriter, r *http.Request) {
- if user := r.Context().Value("user").(*User); user.Session == nil {
- jsonResponse(w, http.StatusBadRequest, Error{
- Error: "User is not logged into WhatsApp",
- ErrCode: "no session",
- })
- } else if contacts, err := user.Session.Contacts.GetAllContacts(); err != nil {
- hlog.FromRequest(r).Err(err).Msg("Failed to fetch all contacts")
- jsonResponse(w, http.StatusInternalServerError, Error{
- Error: "Internal server error while fetching contact list",
- ErrCode: "failed to get contacts",
- })
- } else {
- augmentedContacts := map[types.JID]interface{}{}
- for jid, contact := range contacts {
- var avatarUrl id.ContentURI
- if puppet := prov.bridge.GetPuppetByJID(jid); puppet != nil {
- avatarUrl = puppet.AvatarURL
- }
- augmentedContacts[jid] = map[string]interface{}{
- "Found": contact.Found,
- "FirstName": contact.FirstName,
- "FullName": contact.FullName,
- "PushName": contact.PushName,
- "BusinessName": contact.BusinessName,
- "AvatarURL": avatarUrl,
- }
- }
- jsonResponse(w, http.StatusOK, augmentedContacts)
- }
-}
-
-func (prov *ProvisioningAPI) ListGroups(w http.ResponseWriter, r *http.Request) {
- user := r.Context().Value("user").(*User)
- if user.Session == nil {
- jsonResponse(w, http.StatusBadRequest, Error{
- Error: "User is not logged into WhatsApp",
- ErrCode: "no session",
- })
- return
- }
- if r.Method == http.MethodPost {
- err := user.ResyncGroups(r.URL.Query().Get("create_portals") == "true")
- if err != nil {
- hlog.FromRequest(r).Err(err).Msg("Failed to resync groups")
- jsonResponse(w, http.StatusInternalServerError, Error{
- Error: "Internal server error while resyncing groups",
- ErrCode: "failed to sync groups",
- })
- return
- }
- }
- if groups, err := user.getCachedGroupList(); err != nil {
- hlog.FromRequest(r).Err(err).Msg("Failed to fetch group list")
- jsonResponse(w, http.StatusInternalServerError, Error{
- Error: "Internal server error while fetching group list",
- ErrCode: "failed to get groups",
- })
- } else {
- jsonResponse(w, http.StatusOK, groups)
- }
-}
-
-type OtherUserInfo struct {
- MXID id.UserID `json:"mxid"`
- JID types.JID `json:"jid"`
- Name string `json:"displayname"`
- Avatar id.ContentURI `json:"avatar_url"`
-}
-
-type PortalInfo struct {
- RoomID id.RoomID `json:"room_id"`
- OtherUser *OtherUserInfo `json:"other_user,omitempty"`
- GroupInfo *types.GroupInfo `json:"group_info,omitempty"`
- JustCreated bool `json:"just_created"`
-}
-
-func looksEmaily(str string) bool {
- for _, char := range str {
- // Characters that are usually in emails, but shouldn't be in phone numbers
- if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || char == '@' {
- return true
- }
- }
- return false
-}
-
-func (prov *ProvisioningAPI) resolveIdentifier(w http.ResponseWriter, r *http.Request) (types.JID, *User) {
- number, _ := mux.Vars(r)["number"]
- if strings.HasSuffix(number, "@"+types.DefaultUserServer) {
- jid, _ := types.ParseJID(number)
- number = "+" + jid.User
- }
- if looksEmaily(number) {
- jsonResponse(w, http.StatusBadRequest, Error{
- Error: "WhatsApp only supports phone numbers as user identifiers",
- ErrCode: "number looks like email",
- })
- } else if user := r.Context().Value("user").(*User); !user.IsLoggedIn() {
- jsonResponse(w, http.StatusBadRequest, Error{
- Error: "User is not logged into WhatsApp",
- ErrCode: "no session",
- })
- } else if resp, err := user.Client.IsOnWhatsApp([]string{number}); err != nil {
- jsonResponse(w, http.StatusInternalServerError, Error{
- Error: fmt.Sprintf("Failed to check if number is on WhatsApp: %v", err),
- ErrCode: "error checking number",
- })
- } else if len(resp) == 0 {
- jsonResponse(w, http.StatusInternalServerError, Error{
- Error: "Didn't get a response to checking if the number is on WhatsApp",
- ErrCode: "error checking number",
- })
- } else if !resp[0].IsIn {
- jsonResponse(w, http.StatusNotFound, Error{
- Error: fmt.Sprintf("The server said +%s is not on WhatsApp", resp[0].JID.User),
- ErrCode: "not on whatsapp",
- })
- } else {
- return resp[0].JID, user
- }
- return types.EmptyJID, nil
-}
-
-func (prov *ProvisioningAPI) StartPM(w http.ResponseWriter, r *http.Request) {
- jid, user := prov.resolveIdentifier(w, r)
- if jid.IsEmpty() || user == nil {
- // resolveIdentifier already responded with an error
- return
- }
- portal, puppet, justCreated, err := user.StartPM(r.Context(), jid, "provisioning API PM")
- if err != nil {
- jsonResponse(w, http.StatusInternalServerError, Error{
- Error: fmt.Sprintf("Failed to create portal: %v", err),
- })
- }
- statusCode := http.StatusOK
- if justCreated {
- statusCode = http.StatusCreated
- }
- jsonResponse(w, statusCode, PortalInfo{
- RoomID: portal.MXID,
- OtherUser: &OtherUserInfo{
- JID: puppet.JID,
- MXID: puppet.MXID,
- Name: puppet.Displayname,
- Avatar: puppet.AvatarURL,
- },
- JustCreated: justCreated,
- })
-}
-
-func (prov *ProvisioningAPI) ResolveIdentifier(w http.ResponseWriter, r *http.Request) {
- jid, user := prov.resolveIdentifier(w, r)
- if jid.IsEmpty() || user == nil {
- // resolveIdentifier already responded with an error
- return
- }
- portal := user.GetPortalByJID(jid)
- puppet := user.bridge.GetPuppetByJID(jid)
- jsonResponse(w, http.StatusOK, PortalInfo{
- RoomID: portal.MXID,
- OtherUser: &OtherUserInfo{
- JID: puppet.JID,
- MXID: puppet.MXID,
- Name: puppet.Displayname,
- Avatar: puppet.AvatarURL,
- },
- })
-}
-
-type ReqBulkResolveIdentifier struct {
- Numbers []string `json:"numbers"`
-}
-
-func (prov *ProvisioningAPI) BulkResolveIdentifier(w http.ResponseWriter, r *http.Request) {
- var req ReqBulkResolveIdentifier
- var resp []types.IsOnWhatsAppResponse
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- jsonResponse(w, http.StatusBadRequest, Error{
- Error: "Failed to parse request JSON",
- ErrCode: "bad json",
- })
- } else if user := r.Context().Value("user").(*User); !user.IsLoggedIn() {
- jsonResponse(w, http.StatusBadRequest, Error{
- Error: "User is not logged into WhatsApp",
- ErrCode: "no session",
- })
- } else if resp, err = user.Client.IsOnWhatsApp(req.Numbers); err != nil {
- jsonResponse(w, http.StatusInternalServerError, Error{
- Error: fmt.Sprintf("Failed to check if number is on WhatsApp: %v", err),
- ErrCode: "error checking number",
- })
- } else {
- jsonResponse(w, http.StatusOK, resp)
- }
-}
-
-func (prov *ProvisioningAPI) OpenGroup(w http.ResponseWriter, r *http.Request) {
- groupID, _ := mux.Vars(r)["groupID"]
- if user := r.Context().Value("user").(*User); !user.IsLoggedIn() {
- jsonResponse(w, http.StatusBadRequest, Error{
- Error: "User is not logged into WhatsApp",
- ErrCode: "no session",
- })
- } else if jid, err := types.ParseJID(groupID); err != nil || jid.Server != types.GroupServer || (!strings.ContainsRune(jid.User, '-') && len(jid.User) < 15) {
- jsonResponse(w, http.StatusBadRequest, Error{
- Error: "Invalid group ID",
- ErrCode: "invalid group id",
- })
- } else if info, err := user.Client.GetGroupInfo(jid); err != nil {
- hlog.FromRequest(r).Err(err).Msg("Failed to get group info by JID")
- // TODO return better responses for different errors (like ErrGroupNotFound and ErrNotInGroup)
- jsonResponse(w, http.StatusInternalServerError, Error{
- Error: fmt.Sprintf("Failed to get group info: %v", err),
- ErrCode: "error getting group info",
- })
- } else {
- hlog.FromRequest(r).Debug().Stringer("chat_jid", jid).Msg("Importing group chat for user")
- portal := user.GetPortalByJID(info.JID)
- statusCode := http.StatusOK
- if len(portal.MXID) == 0 {
- err = portal.CreateMatrixRoom(r.Context(), user, info, nil, true, true)
- if err != nil {
- jsonResponse(w, http.StatusInternalServerError, Error{
- Error: fmt.Sprintf("Failed to create portal: %v", err),
- })
- return
- }
- statusCode = http.StatusCreated
- }
- jsonResponse(w, statusCode, PortalInfo{
- RoomID: portal.MXID,
- GroupInfo: info,
- JustCreated: statusCode == http.StatusCreated,
- })
- }
-}
-
-func (prov *ProvisioningAPI) resolveGroupInvite(w http.ResponseWriter, r *http.Request) (*types.GroupInfo, *User) {
- inviteCode, _ := mux.Vars(r)["inviteCode"]
- if user := r.Context().Value("user").(*User); !user.IsLoggedIn() {
- jsonResponse(w, http.StatusBadRequest, Error{
- Error: "User is not logged into WhatsApp",
- ErrCode: "no session",
- })
- } else if info, err := user.Client.GetGroupInfoFromLink(inviteCode); err != nil {
- if errors.Is(err, whatsmeow.ErrInviteLinkRevoked) {
- jsonResponse(w, http.StatusBadRequest, Error{
- Error: whatsmeow.ErrInviteLinkRevoked.Error(),
- ErrCode: "invite link revoked",
- })
- } else if errors.Is(err, whatsmeow.ErrInviteLinkInvalid) {
- jsonResponse(w, http.StatusBadRequest, Error{
- Error: whatsmeow.ErrInviteLinkInvalid.Error(),
- ErrCode: "invalid invite link",
- })
- } else {
- hlog.FromRequest(r).Err(err).Msg("Failed to get group info from link")
- jsonResponse(w, http.StatusInternalServerError, Error{
- Error: fmt.Sprintf("Failed to fetch group info with link: %v", err),
- ErrCode: "error getting group info",
- })
- }
- } else {
- return info, user
- }
- return nil, nil
-}
-
-func (prov *ProvisioningAPI) ResolveGroupInvite(w http.ResponseWriter, r *http.Request) {
- info, user := prov.resolveGroupInvite(w, r)
- if info == nil {
- return
- }
- jsonResponse(w, http.StatusOK, PortalInfo{
- RoomID: user.GetPortalByJID(info.JID).MXID,
- GroupInfo: info,
- })
-}
-
-func (prov *ProvisioningAPI) JoinGroup(w http.ResponseWriter, r *http.Request) {
- info, user := prov.resolveGroupInvite(w, r)
- if info == nil {
- return
- }
- user.groupJoinLock.Lock()
- user.skipGroupCreateDelay = info.JID
- defer func() {
- user.skipGroupCreateDelay = types.EmptyJID
- user.groupJoinLock.Unlock()
- }()
- inviteCode, _ := mux.Vars(r)["inviteCode"]
- if jid, err := user.Client.JoinGroupWithLink(inviteCode); err != nil {
- hlog.FromRequest(r).Err(err).Msg("Failed to join group")
- jsonResponse(w, http.StatusInternalServerError, Error{
- Error: fmt.Sprintf("Failed to join group: %v", err),
- ErrCode: "error joining group",
- })
- } else {
- hlog.FromRequest(r).Debug().Stringer("chat_jid", jid).Msg("Successfully joined group")
- portal := user.GetPortalByJID(jid)
- statusCode := http.StatusOK
- if len(portal.MXID) == 0 {
- time.Sleep(500 * time.Millisecond) // Wait for incoming group info to create the portal automatically
- err = portal.CreateMatrixRoom(r.Context(), user, info, nil, true, true)
- if err != nil {
- jsonResponse(w, http.StatusInternalServerError, Error{
- Error: fmt.Sprintf("Failed to create portal: %v", err),
- })
- return
- }
- statusCode = http.StatusCreated
- }
- jsonResponse(w, statusCode, PortalInfo{
- RoomID: portal.MXID,
- GroupInfo: info,
- JustCreated: statusCode == http.StatusCreated,
- })
- }
-}
-
-func (prov *ProvisioningAPI) Ping(w http.ResponseWriter, r *http.Request) {
- user := r.Context().Value("user").(*User)
- wa := map[string]interface{}{
- "has_session": user.Session != nil,
- "management_room": user.ManagementRoom,
- "conn": nil,
- }
- if !user.JID.IsEmpty() {
- wa["jid"] = user.JID.String()
- wa["phone"] = "+" + user.JID.User
- wa["device"] = user.JID.Device
- if user.Session != nil {
- wa["platform"] = user.Session.Platform
- }
- }
- if user.Client != nil {
- wa["conn"] = map[string]interface{}{
- "is_connected": user.Client.IsConnected(),
- "is_logged_in": user.Client.IsLoggedIn(),
- }
- }
- resp := map[string]interface{}{
- "mxid": user.MXID,
- "admin": user.Admin,
- "whitelisted": user.Whitelisted,
- "relay_whitelisted": user.RelayWhitelisted,
- "whatsapp": wa,
- }
- jsonResponse(w, http.StatusOK, resp)
-}
-
-func jsonResponse(w http.ResponseWriter, status int, response interface{}) {
- w.Header().Add("Content-Type", "application/json")
- w.WriteHeader(status)
- _ = json.NewEncoder(w).Encode(response)
-}
-
-func (prov *ProvisioningAPI) Logout(w http.ResponseWriter, r *http.Request) {
- user := r.Context().Value("user").(*User)
- if user.Session == nil {
- jsonResponse(w, http.StatusOK, Error{
- Error: "You're not logged in",
- ErrCode: "not logged in",
- })
- return
- }
-
- force := strings.ToLower(r.URL.Query().Get("force")) != "false"
-
- if user.Client == nil {
- if !force {
- jsonResponse(w, http.StatusNotFound, Error{
- Error: "You're not connected",
- ErrCode: "not connected",
- })
- }
- } else {
- err := user.Client.Logout()
- if err != nil {
- hlog.FromRequest(r).Err(err).Msg("Unknown error while logging out")
- if !force {
- jsonResponse(w, http.StatusInternalServerError, Error{
- Error: fmt.Sprintf("Unknown error while logging out: %v", err),
- ErrCode: err.Error(),
- })
- return
- }
- } else {
- user.Session = nil
- }
- user.DeleteConnection()
- }
-
- user.bridge.Metrics.TrackConnectionState(user.JID, false)
- user.removeFromJIDMap(status.BridgeState{StateEvent: status.StateLoggedOut})
- user.DeleteSession(r.Context())
- jsonResponse(w, http.StatusOK, Response{true, "Logged out successfully."})
-}
-
-var upgrader = websocket.Upgrader{
- CheckOrigin: func(r *http.Request) bool {
- return true
- },
- Subprotocols: []string{"net.maunium.whatsapp.login"},
-}
-
-var notNumbers = regexp.MustCompile("[^0-9]")
-
-func (prov *ProvisioningAPI) Login(w http.ResponseWriter, r *http.Request) {
- userID := r.URL.Query().Get("user_id")
- user := prov.bridge.GetUserByMXID(id.UserID(userID))
- log := hlog.FromRequest(r)
-
- c, err := upgrader.Upgrade(w, r, nil)
- if err != nil {
- log.Err(err).Msg("Failed to upgrade connection to websocket")
- return
- }
- defer func() {
- err := c.Close()
- if err != nil {
- log.Debug().Err(err).Msg("Error closing websocket")
- }
- }()
-
- go func() {
- // Read everything so SetCloseHandler() works
- for {
- _, _, err = c.ReadMessage()
- if err != nil {
- break
- }
- }
- }()
- ctx, cancel := context.WithCancel(context.Background())
- c.SetCloseHandler(func(code int, text string) error {
- log.Debug().Int("close_code", code).Msg("Login websocket closed, cancelling login")
- cancel()
- return nil
- })
-
- if userTimezone := r.URL.Query().Get("tz"); userTimezone != "" {
- log.Debug().Str("timezone", userTimezone).Msg("Updating user timezone")
- user.Timezone = userTimezone
- err = user.Update(r.Context())
- if err != nil {
- log.Err(err).Msg("Failed to save user after updating timezone")
- }
- } else {
- log.Debug().Msg("No timezone provided in request")
- }
-
- qrChan, qrReceivedChan, err := user.Login(ctx)
- expiryTime := time.Now().Add(160 * time.Second)
- if err != nil {
- log.Err(err).Msg("Failed to log in via provisioning API")
- if errors.Is(err, ErrAlreadyLoggedIn) {
- go user.Connect()
- _ = c.WriteJSON(Error{
- Error: "You're already logged into WhatsApp",
- ErrCode: "already logged in",
- })
- } else {
- _ = c.WriteJSON(Error{
- Error: "Failed to connect to WhatsApp",
- ErrCode: "connection error",
- })
- }
- return
- }
- phoneNum := r.URL.Query().Get("phone_number")
- if phoneNum != "" {
- rawPhone := phoneNum
- phoneNum = notNumbers.ReplaceAllString(phoneNum, "")
- if len(phoneNum) < 7 || strings.HasPrefix(phoneNum, "0") {
- log.Warn().Str("phone", rawPhone).Msg("Invalid phone number in login request")
- Analytics.Track(user.MXID, "$login_failure", map[string]any{
- "error": "invalid phone number",
- "phone": rawPhone,
- })
- errorMsg := "Invalid phone number"
- if len(phoneNum) > 6 {
- errorMsg = "Please enter the phone number in international format"
- }
- _ = c.WriteJSON(Error{
- Error: errorMsg,
- ErrCode: "invalid phone number",
- })
- go user.DeleteConnection()
- return
- }
- select {
- case <-qrReceivedChan:
- case <-time.After(5 * time.Second):
- log.Warn().Msg("Didn't receive QR event within 5 seconds of starting login")
- }
- pairingCode, err := user.Client.PairPhone(phoneNum, true, whatsmeow.PairClientChrome, "Chrome (Linux)")
- if err != nil {
- log.Err(err).Msg("Failed to start phone code login")
- Analytics.Track(user.MXID, "$login_failure", map[string]any{
- "error": "phone code start fail",
- "go_error": err.Error(),
- })
- _ = c.WriteJSON(Error{
- Error: "Failed to request pairing code",
- ErrCode: "code error",
- })
- go user.DeleteConnection()
- return
- } else {
- log.Debug().Msg("Started phone number login")
- _ = c.WriteJSON(map[string]any{
- "pairing_code": pairingCode,
- "timeout": int(time.Until(expiryTime).Seconds()),
- })
- }
- }
-
- log.Debug().Msg("Started login via provisioning API")
- Analytics.Track(user.MXID, "$login_start")
-
- for {
- select {
- case evt := <-qrChan:
- switch evt.Event {
- case whatsmeow.QRChannelSuccess.Event:
- jid := user.Client.Store.ID
- log.Debug().Stringer("jid", jid).Msg("Successful login via provisioning API")
- Analytics.Track(user.MXID, "$login_success")
- _ = c.WriteJSON(map[string]interface{}{
- "success": true,
- "jid": jid,
- "phone": fmt.Sprintf("+%s", jid.User),
- "platform": user.Client.Store.Platform,
- })
- case whatsmeow.QRChannelTimeout.Event:
- log.Debug().Msg("Login via provisioning API timed out")
- errCode := "login timed out"
- Analytics.Track(user.MXID, "$login_failure", map[string]interface{}{"error": errCode})
- _ = c.WriteJSON(Error{
- Error: "QR code scan timed out. Please try again.",
- ErrCode: errCode,
- })
- case whatsmeow.QRChannelErrUnexpectedEvent.Event:
- log.Debug().Msg("Login via provisioning API failed due to unexpected event")
- errCode := "unexpected event"
- Analytics.Track(user.MXID, "$login_failure", map[string]interface{}{"error": errCode})
- _ = c.WriteJSON(Error{
- Error: "Got unexpected event while waiting for QRs, perhaps you're already logged in?",
- ErrCode: errCode,
- })
- case whatsmeow.QRChannelClientOutdated.Event:
- log.Debug().Msg("Login via provisioning API failed due to outdated client")
- errCode := "bridge outdated"
- Analytics.Track(user.MXID, "$login_failure", map[string]interface{}{"error": errCode})
- _ = c.WriteJSON(Error{
- Error: "Got client outdated error while waiting for QRs. The bridge must be updated to continue.",
- ErrCode: errCode,
- })
- case whatsmeow.QRChannelScannedWithoutMultidevice.Event:
- errCode := "multidevice not enabled"
- Analytics.Track(user.MXID, "$login_failure", map[string]interface{}{"error": errCode})
- _ = c.WriteJSON(Error{
- Error: "Please enable the WhatsApp multidevice beta and scan the QR code again.",
- ErrCode: errCode,
- })
- continue
- case "error":
- errCode := "fatal error"
- Analytics.Track(user.MXID, "$login_failure", map[string]interface{}{"error": errCode})
- _ = c.WriteJSON(Error{
- Error: "Fatal error while logging in",
- ErrCode: errCode,
- })
- case "code":
- Analytics.Track(user.MXID, "$qrcode_retrieved")
- _ = c.WriteJSON(map[string]interface{}{
- "code": evt.Code,
- "timeout": int(evt.Timeout.Seconds()),
- })
- continue
- }
- return
- case <-ctx.Done():
- return
- }
- }
-}
diff --git a/puppet.go b/puppet.go
deleted file mode 100644
index 46ba62a..0000000
--- a/puppet.go
+++ /dev/null
@@ -1,422 +0,0 @@
-// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
-// Copyright (C) 2021 Tulir Asokan
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package main
-
-import (
- "context"
- "fmt"
- "regexp"
- "sync"
- "time"
-
- "github.com/rs/zerolog"
- "go.mau.fi/whatsmeow/types"
-
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/appservice"
- "maunium.net/go/mautrix/bridge"
- "maunium.net/go/mautrix/id"
-
- "maunium.net/go/mautrix-whatsapp/config"
- "maunium.net/go/mautrix-whatsapp/database"
-)
-
-var userIDRegex *regexp.Regexp
-
-func (br *WABridge) ParsePuppetMXID(mxid id.UserID) (jid types.JID, ok bool) {
- if userIDRegex == nil {
- userIDRegex = br.Config.MakeUserIDRegex("([0-9]+)")
- }
- match := userIDRegex.FindStringSubmatch(string(mxid))
- if len(match) == 2 {
- jid = types.NewJID(match[1], types.DefaultUserServer)
- ok = true
- }
- return
-}
-
-func (br *WABridge) GetPuppetByMXID(mxid id.UserID) *Puppet {
- jid, ok := br.ParsePuppetMXID(mxid)
- if !ok {
- return nil
- }
-
- return br.GetPuppetByJID(jid)
-}
-
-func (br *WABridge) GetPuppetByJID(jid types.JID) *Puppet {
- ctx := context.TODO()
- jid = jid.ToNonAD()
- if jid.Server == types.LegacyUserServer {
- jid.Server = types.DefaultUserServer
- } else if jid.Server != types.DefaultUserServer {
- return nil
- }
- br.puppetsLock.Lock()
- defer br.puppetsLock.Unlock()
- puppet, ok := br.puppets[jid]
- if !ok {
- dbPuppet, err := br.DB.Puppet.Get(ctx, jid)
- if err != nil {
- br.ZLog.Err(err).Stringer("jid", jid).Msg("Failed to get puppet from database")
- return nil
- }
- if dbPuppet == nil {
- dbPuppet = br.DB.Puppet.New()
- dbPuppet.JID = jid
- err = dbPuppet.Insert(ctx)
- if err != nil {
- br.ZLog.Err(err).Stringer("jid", jid).Msg("Failed to insert new puppet to database")
- return nil
- }
- }
- puppet = br.NewPuppet(dbPuppet)
- br.puppets[puppet.JID] = puppet
- if len(puppet.CustomMXID) > 0 {
- br.puppetsByCustomMXID[puppet.CustomMXID] = puppet
- }
- }
- return puppet
-}
-
-func (br *WABridge) GetPuppetByCustomMXID(mxid id.UserID) *Puppet {
- br.puppetsLock.Lock()
- defer br.puppetsLock.Unlock()
- puppet, ok := br.puppetsByCustomMXID[mxid]
- if !ok {
- dbPuppet, err := br.DB.Puppet.GetByCustomMXID(context.TODO(), mxid)
- if err != nil {
- br.ZLog.Err(err).Stringer("mxid", mxid).Msg("Failed to get puppet by custom mxid from database")
- }
- if dbPuppet == nil {
- return nil
- }
- puppet = br.NewPuppet(dbPuppet)
- br.puppets[puppet.JID] = puppet
- br.puppetsByCustomMXID[puppet.CustomMXID] = puppet
- }
- return puppet
-}
-
-func (user *User) GetIDoublePuppet() bridge.DoublePuppet {
- p := user.bridge.GetPuppetByCustomMXID(user.MXID)
- if p == nil || p.CustomIntent() == nil {
- return nil
- }
- return p
-}
-
-func (user *User) GetIGhost() bridge.Ghost {
- if user.JID.IsEmpty() {
- return nil
- }
- p := user.bridge.GetPuppetByJID(user.JID)
- if p == nil {
- return nil
- }
- return p
-}
-
-func (br *WABridge) IsGhost(id id.UserID) bool {
- _, ok := br.ParsePuppetMXID(id)
- return ok
-}
-
-func (br *WABridge) GetIGhost(id id.UserID) bridge.Ghost {
- p := br.GetPuppetByMXID(id)
- if p == nil {
- return nil
- }
- return p
-}
-
-func (puppet *Puppet) GetMXID() id.UserID {
- return puppet.MXID
-}
-
-func (br *WABridge) GetAllPuppetsWithCustomMXID() []*Puppet {
- return br.dbPuppetsToPuppets(br.DB.Puppet.GetAllWithCustomMXID(context.TODO()))
-}
-
-func (br *WABridge) GetAllPuppets() []*Puppet {
- return br.dbPuppetsToPuppets(br.DB.Puppet.GetAll(context.TODO()))
-}
-
-func (br *WABridge) dbPuppetsToPuppets(dbPuppets []*database.Puppet, err error) []*Puppet {
- if err != nil {
- br.ZLog.Err(err).Msg("Error getting puppets from database")
- return nil
- }
- br.puppetsLock.Lock()
- defer br.puppetsLock.Unlock()
- output := make([]*Puppet, len(dbPuppets))
- for index, dbPuppet := range dbPuppets {
- if dbPuppet == nil {
- continue
- }
- puppet, ok := br.puppets[dbPuppet.JID]
- if !ok {
- puppet = br.NewPuppet(dbPuppet)
- br.puppets[dbPuppet.JID] = puppet
- if len(dbPuppet.CustomMXID) > 0 {
- br.puppetsByCustomMXID[dbPuppet.CustomMXID] = puppet
- }
- }
- output[index] = puppet
- }
- return output
-}
-
-func (br *WABridge) FormatPuppetMXID(jid types.JID) id.UserID {
- return id.NewUserID(
- br.Config.Bridge.FormatUsername(jid.User),
- br.Config.Homeserver.Domain)
-}
-
-func (br *WABridge) NewPuppet(dbPuppet *database.Puppet) *Puppet {
- return &Puppet{
- Puppet: dbPuppet,
- bridge: br,
- zlog: br.ZLog.With().Stringer("puppet_jid", dbPuppet.JID).Logger(),
-
- MXID: br.FormatPuppetMXID(dbPuppet.JID),
- }
-}
-
-type Puppet struct {
- *database.Puppet
-
- bridge *WABridge
- zlog zerolog.Logger
-
- typingIn id.RoomID
- typingAt time.Time
-
- MXID id.UserID
-
- customIntent *appservice.IntentAPI
- customUser *User
-
- syncLock sync.Mutex
-}
-
-var _ bridge.GhostWithProfile = (*Puppet)(nil)
-
-func (puppet *Puppet) GetDisplayname() string {
- return puppet.Displayname
-}
-
-func (puppet *Puppet) GetAvatarURL() id.ContentURI {
- return puppet.AvatarURL
-}
-
-func (puppet *Puppet) IntentFor(portal *Portal) *appservice.IntentAPI {
- if puppet.customIntent == nil || portal.Key.JID == puppet.JID || (portal.Key.JID.Server == types.BroadcastServer && portal.Key.Receiver != puppet.JID) {
- return puppet.DefaultIntent()
- }
- return puppet.customIntent
-}
-
-func (puppet *Puppet) CustomIntent() *appservice.IntentAPI {
- return puppet.customIntent
-}
-
-func (puppet *Puppet) DefaultIntent() *appservice.IntentAPI {
- return puppet.bridge.AS.Intent(puppet.MXID)
-}
-
-func (puppet *Puppet) UpdateAvatar(ctx context.Context, source *User, forcePortalSync bool) bool {
- changed := source.updateAvatar(ctx, puppet.JID, false, &puppet.Avatar, &puppet.AvatarURL, &puppet.AvatarSet, puppet.DefaultIntent())
- if !changed || puppet.Avatar == "unauthorized" {
- if forcePortalSync {
- go puppet.updatePortalAvatar(ctx)
- }
- return changed
- }
- err := puppet.DefaultIntent().SetAvatarURL(ctx, puppet.AvatarURL)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to set avatar from puppet")
- } else {
- puppet.AvatarSet = true
- }
- go puppet.updatePortalAvatar(ctx)
- return true
-}
-
-func (puppet *Puppet) UpdateName(ctx context.Context, contact types.ContactInfo, forcePortalSync bool) bool {
- newName, quality := puppet.bridge.Config.Bridge.FormatDisplayname(puppet.JID, contact)
- if (puppet.Displayname != newName || !puppet.NameSet) && quality >= puppet.NameQuality {
- oldName := puppet.Displayname
- puppet.Displayname = newName
- puppet.NameQuality = quality
- puppet.NameSet = false
- err := puppet.DefaultIntent().SetDisplayName(ctx, newName)
- if err == nil {
- puppet.zlog.Debug().Str("old_name", oldName).Str("new_name", newName).Msg("Updated name")
- puppet.NameSet = true
- go puppet.updatePortalName(ctx)
- } else {
- puppet.zlog.Err(err).Msg("Failed to set displayname")
- }
- return true
- } else if forcePortalSync {
- go puppet.updatePortalName(ctx)
- }
- return false
-}
-
-func (puppet *Puppet) UpdateContactInfo(ctx context.Context) bool {
- if !puppet.bridge.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) {
- return false
- }
-
- if puppet.ContactInfoSet {
- return false
- }
-
- contactInfo := map[string]any{
- "com.beeper.bridge.identifiers": []string{
- fmt.Sprintf("tel:+%s", puppet.JID.User),
- fmt.Sprintf("whatsapp:%s", puppet.JID.String()),
- },
- "com.beeper.bridge.remote_id": puppet.JID.String(),
- "com.beeper.bridge.service": "whatsapp",
- "com.beeper.bridge.network": "whatsapp",
- }
- err := puppet.DefaultIntent().BeeperUpdateProfile(ctx, contactInfo)
- if err != nil {
- puppet.zlog.Err(err).Msg("Failed to store custom contact info in profile")
- return false
- } else {
- puppet.ContactInfoSet = true
- return true
- }
-}
-
-func (puppet *Puppet) updatePortalMeta(meta func(portal *Portal)) {
- for _, portal := range puppet.bridge.GetAllPortalsByJID(puppet.JID) {
- // Get room create lock to prevent races between receiving contact info and room creation.
- portal.roomCreateLock.Lock()
- meta(portal)
- portal.roomCreateLock.Unlock()
- }
-}
-
-func (puppet *Puppet) updatePortalAvatar(ctx context.Context) {
- puppet.updatePortalMeta(func(portal *Portal) {
- if portal.Avatar == puppet.Avatar && portal.AvatarURL == puppet.AvatarURL && (portal.AvatarSet || !portal.shouldSetDMRoomMetadata()) {
- return
- }
- portal.AvatarURL = puppet.AvatarURL
- portal.Avatar = puppet.Avatar
- portal.AvatarSet = false
- if len(portal.MXID) > 0 && !portal.shouldSetDMRoomMetadata() {
- portal.UpdateBridgeInfo(ctx)
- } else if len(portal.MXID) > 0 {
- _, err := portal.MainIntent().SetRoomAvatar(ctx, portal.MXID, puppet.AvatarURL)
- if err != nil {
- portal.zlog.Err(err).Msg("Failed to set avatar from puppet")
- } else {
- portal.AvatarSet = true
- portal.UpdateBridgeInfo(ctx)
- }
- }
- err := portal.Update(ctx)
- if err != nil {
- portal.zlog.Err(err).Msg("Failed to save portal after updating avatar from puppet")
- }
- })
-}
-
-func (puppet *Puppet) updatePortalName(ctx context.Context) {
- puppet.updatePortalMeta(func(portal *Portal) {
- portal.UpdateName(ctx, puppet.Displayname, types.EmptyJID, true)
- })
-}
-
-func (puppet *Puppet) SyncContact(ctx context.Context, source *User, onlyIfNoName, shouldHavePushName bool, reason string) {
- if puppet == nil {
- return
- }
- if onlyIfNoName && len(puppet.Displayname) > 0 && (!shouldHavePushName || puppet.NameQuality > config.NameQualityPhone) {
- source.EnqueuePuppetResync(puppet)
- return
- }
- log := zerolog.Ctx(ctx).With().
- Str("method", "Puppet.SyncContact").
- Stringer("puppet_jid", puppet.JID).
- Stringer("source_user_jid", source.JID).
- Stringer("source_user_mxid", source.MXID).
- Logger()
- ctx = log.WithContext(ctx)
-
- contact, err := source.Client.Store.Contacts.GetContact(puppet.JID)
- if err != nil {
- log.Err(err).
- Stringer("source_mxid", source.MXID).
- Str("sync_reason", reason).
- Msg("Failed to get contact info through user in SyncContact")
- } else if !contact.Found {
- log.Warn().
- Stringer("source_mxid", source.MXID).
- Str("sync_reason", reason).
- Msg("No contact info found through user in SyncContact")
- }
- puppet.syncInternal(ctx, source, &contact, false, false)
-}
-
-func (puppet *Puppet) Sync(ctx context.Context, source *User, contact *types.ContactInfo, forceAvatarSync, forcePortalSync bool) {
- log := zerolog.Ctx(ctx).With().
- Str("method", "Puppet.Sync").
- Stringer("puppet_jid", puppet.JID).
- Stringer("source_user_jid", source.JID).
- Stringer("source_user_mxid", source.MXID).
- Logger()
- ctx = log.WithContext(ctx)
- puppet.syncInternal(ctx, source, contact, forceAvatarSync, forcePortalSync)
-}
-
-func (puppet *Puppet) syncInternal(ctx context.Context, source *User, contact *types.ContactInfo, forceAvatarSync, forcePortalSync bool) {
- log := zerolog.Ctx(ctx)
- puppet.syncLock.Lock()
- defer puppet.syncLock.Unlock()
- err := puppet.DefaultIntent().EnsureRegistered(ctx)
- if err != nil {
- log.Err(err).Msg("Failed to ensure registered")
- }
-
- log.Debug().Stringer("source_jid", source.JID).Msg("Syncing info through user")
-
- update := false
- if contact != nil {
- if puppet.JID.User == source.JID.User {
- contact.PushName = source.Client.Store.PushName
- }
- update = puppet.UpdateName(ctx, *contact, forcePortalSync) || update
- }
- if len(puppet.Avatar) == 0 || forceAvatarSync || puppet.bridge.Config.Bridge.UserAvatarSync {
- update = puppet.UpdateAvatar(ctx, source, forcePortalSync) || update
- }
- update = puppet.UpdateContactInfo(ctx) || update
- if update || puppet.LastSync.Add(24*time.Hour).Before(time.Now()) {
- puppet.LastSync = time.Now()
- err = puppet.Update(ctx)
- if err != nil {
- log.Err(err).Msg("Failed to save puppet after sync")
- }
- }
-}
diff --git a/urlpreview.go b/urlpreview.go
deleted file mode 100644
index f40441d..0000000
--- a/urlpreview.go
+++ /dev/null
@@ -1,195 +0,0 @@
-// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
-// Copyright (C) 2024 Tulir Asokan
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package main
-
-import (
- "bytes"
- "context"
- "image"
- "net/http"
- "net/url"
- "regexp"
- "strings"
- "time"
-
- "github.com/rs/zerolog"
- "golang.org/x/net/idna"
- "google.golang.org/protobuf/proto"
-
- "go.mau.fi/whatsmeow"
- waProto "go.mau.fi/whatsmeow/binary/proto"
-
- "maunium.net/go/mautrix/appservice"
- "maunium.net/go/mautrix/crypto/attachment"
- "maunium.net/go/mautrix/event"
-)
-
-func (portal *Portal) convertURLPreviewToBeeper(ctx context.Context, intent *appservice.IntentAPI, source *User, msg *waProto.ExtendedTextMessage) []*event.BeeperLinkPreview {
- if msg.GetMatchedText() == "" {
- return []*event.BeeperLinkPreview{}
- }
-
- output := &event.BeeperLinkPreview{
- MatchedURL: msg.GetMatchedText(),
- LinkPreview: event.LinkPreview{
- CanonicalURL: msg.GetCanonicalURL(),
- Title: msg.GetTitle(),
- Description: msg.GetDescription(),
- },
- }
- if len(output.CanonicalURL) == 0 {
- output.CanonicalURL = output.MatchedURL
- }
-
- var thumbnailData []byte
- if msg.ThumbnailDirectPath != nil {
- var err error
- thumbnailData, err = source.Client.DownloadThumbnail(msg)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to download thumbnail for link preview")
- }
- }
- if thumbnailData == nil && msg.JPEGThumbnail != nil {
- thumbnailData = msg.JPEGThumbnail
- }
- if thumbnailData != nil {
- output.ImageHeight = int(msg.GetThumbnailHeight())
- output.ImageWidth = int(msg.GetThumbnailWidth())
- if output.ImageHeight == 0 || output.ImageWidth == 0 {
- src, _, err := image.Decode(bytes.NewReader(thumbnailData))
- if err == nil {
- imageBounds := src.Bounds()
- output.ImageWidth, output.ImageHeight = imageBounds.Max.X, imageBounds.Max.Y
- }
- }
- output.ImageSize = len(thumbnailData)
- output.ImageType = http.DetectContentType(thumbnailData)
- uploadData, uploadMime := thumbnailData, output.ImageType
- if portal.Encrypted {
- crypto := attachment.NewEncryptedFile()
- crypto.EncryptInPlace(uploadData)
- uploadMime = "application/octet-stream"
- output.ImageEncryption = &event.EncryptedFileInfo{EncryptedFile: *crypto}
- }
- resp, err := intent.UploadBytes(ctx, uploadData, uploadMime)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to reupload thumbnail for link preview")
- } else {
- if output.ImageEncryption != nil {
- output.ImageEncryption.URL = resp.ContentURI.CUString()
- } else {
- output.ImageURL = resp.ContentURI.CUString()
- }
- }
- }
- if msg.GetPreviewType() == waProto.ExtendedTextMessage_VIDEO {
- output.Type = "video.other"
- }
-
- return []*event.BeeperLinkPreview{output}
-}
-
-var URLRegex = regexp.MustCompile(`https?://[^\s/_*]+(?:/\S*)?`)
-
-func (portal *Portal) convertURLPreviewToWhatsApp(ctx context.Context, sender *User, content *event.MessageEventContent, dest *waProto.ExtendedTextMessage) bool {
- log := zerolog.Ctx(ctx)
- var preview *event.BeeperLinkPreview
-
- if content.BeeperLinkPreviews != nil {
- // Note: this check explicitly happens after checking for nil: empty arrays are treated as no previews,
- // but omitting the field means the bridge may look for URLs in the message text.
- if len(content.BeeperLinkPreviews) == 0 {
- return false
- }
- // WhatsApp only supports a single preview.
- preview = content.BeeperLinkPreviews[0]
- } else if portal.bridge.Config.Bridge.URLPreviews {
- if matchedURL := URLRegex.FindString(content.Body); len(matchedURL) == 0 {
- return false
- } else if parsed, err := url.Parse(matchedURL); err != nil {
- return false
- } else if parsed.Host, err = idna.ToASCII(parsed.Host); err != nil {
- return false
- } else if mxPreview, err := portal.MainIntent().GetURLPreview(ctx, parsed.String()); err != nil {
- log.Err(err).Str("url", matchedURL).Msg("Failed to fetch URL preview")
- return false
- } else {
- preview = &event.BeeperLinkPreview{
- LinkPreview: *mxPreview,
- MatchedURL: matchedURL,
- }
- }
- }
- if preview == nil || len(preview.MatchedURL) == 0 {
- return false
- }
-
- dest.MatchedText = &preview.MatchedURL
- if len(preview.CanonicalURL) > 0 {
- dest.CanonicalURL = &preview.CanonicalURL
- }
- if len(preview.Description) > 0 {
- dest.Description = &preview.Description
- }
- if len(preview.Title) > 0 {
- dest.Title = &preview.Title
- }
- if strings.HasPrefix(preview.Type, "video.") {
- dest.PreviewType = waProto.ExtendedTextMessage_VIDEO.Enum()
- }
- imageMXC := preview.ImageURL.ParseOrIgnore()
- if preview.ImageEncryption != nil {
- imageMXC = preview.ImageEncryption.URL.ParseOrIgnore()
- }
- if !imageMXC.IsEmpty() {
- data, err := portal.MainIntent().DownloadBytes(ctx, imageMXC)
- if err != nil {
- log.Err(err).Str("image_url", string(preview.ImageURL)).Msg("Failed to download URL preview image")
- return true
- }
- if preview.ImageEncryption != nil {
- err = preview.ImageEncryption.DecryptInPlace(data)
- if err != nil {
- log.Err(err).Msg("Failed to decrypt URL preview image")
- return true
- }
- }
- dest.MediaKeyTimestamp = proto.Int64(time.Now().Unix())
- uploadResp, err := sender.Client.Upload(ctx, data, whatsmeow.MediaLinkThumbnail)
- if err != nil {
- log.Err(err).Msg("Failed to reupload URL preview thumbnail")
- return true
- }
- dest.ThumbnailSHA256 = uploadResp.FileSHA256
- dest.ThumbnailEncSHA256 = uploadResp.FileEncSHA256
- dest.ThumbnailDirectPath = &uploadResp.DirectPath
- dest.MediaKey = uploadResp.MediaKey
- var width, height int
- dest.JPEGThumbnail, width, height, err = createThumbnailAndGetSize(data, false)
- if err != nil {
- log.Err(err).Msg("Failed to create JPEG thumbnail for URL preview")
- }
- if preview.ImageHeight > 0 && preview.ImageWidth > 0 {
- dest.ThumbnailWidth = proto.Uint32(uint32(preview.ImageWidth))
- dest.ThumbnailHeight = proto.Uint32(uint32(preview.ImageHeight))
- } else if width > 0 && height > 0 {
- dest.ThumbnailWidth = proto.Uint32(uint32(width))
- dest.ThumbnailHeight = proto.Uint32(uint32(height))
- }
- }
- return true
-}
diff --git a/user.go b/user.go
deleted file mode 100644
index 4558b87..0000000
--- a/user.go
+++ /dev/null
@@ -1,1639 +0,0 @@
-// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
-// Copyright (C) 2024 Tulir Asokan
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package main
-
-import (
- "context"
- "crypto/hmac"
- "crypto/sha256"
- "encoding/binary"
- "encoding/json"
- "errors"
- "fmt"
- "math"
- "math/rand"
- "net/http"
- "net/url"
- "strconv"
- "strings"
- "sync"
- "sync/atomic"
- "time"
-
- "github.com/rs/zerolog"
- "go.mau.fi/util/exzerolog"
- "go.mau.fi/whatsmeow"
- "go.mau.fi/whatsmeow/appstate"
- waProto "go.mau.fi/whatsmeow/binary/proto"
- "go.mau.fi/whatsmeow/store"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
- waLog "go.mau.fi/whatsmeow/util/log"
- "golang.org/x/sync/semaphore"
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/appservice"
- "maunium.net/go/mautrix/bridge"
- "maunium.net/go/mautrix/bridge/bridgeconfig"
- "maunium.net/go/mautrix/bridge/status"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/format"
- "maunium.net/go/mautrix/id"
- "maunium.net/go/mautrix/pushrules"
-
- "maunium.net/go/mautrix-whatsapp/database"
-)
-
-type User struct {
- *database.User
- Client *whatsmeow.Client
- Session *store.Device
-
- bridge *WABridge
- zlog zerolog.Logger
-
- Admin bool
- Whitelisted bool
- RelayWhitelisted bool
- PermissionLevel bridgeconfig.PermissionLevel
-
- mgmtCreateLock sync.Mutex
- spaceCreateLock sync.Mutex
- connLock sync.Mutex
-
- historySyncs chan *events.HistorySync
- lastPresence types.Presence
-
- mediaRetryLock *semaphore.Weighted
-
- historySyncLoopsStarted bool
- enqueueBackfillsTimer *time.Timer
- spaceMembershipChecked bool
- lastPhoneOfflineWarning time.Time
-
- groupListCache []*types.GroupInfo
- groupListCacheLock sync.Mutex
- groupListCacheTime time.Time
-
- BackfillQueue *BackfillQueue
- BridgeState *bridge.BridgeStateQueue
-
- resyncQueue map[types.JID]resyncQueueItem
- resyncQueueLock sync.Mutex
- nextResync time.Time
-
- createKeyDedup string
- skipGroupCreateDelay types.JID
- groupJoinLock sync.Mutex
-
- qrReceived chan struct{}
- qrWaiting atomic.Bool
-}
-
-type resyncQueueItem struct {
- portal *Portal
- puppet *Puppet
-}
-
-func (br *WABridge) getUserByMXID(userID id.UserID, onlyIfExists bool) *User {
- _, isPuppet := br.ParsePuppetMXID(userID)
- if isPuppet || userID == br.Bot.UserID {
- return nil
- }
- br.usersLock.Lock()
- defer br.usersLock.Unlock()
- user, ok := br.usersByMXID[userID]
- if !ok {
- userIDPtr := &userID
- if onlyIfExists {
- userIDPtr = nil
- }
- ctx := context.TODO()
- dbUser, err := br.DB.User.GetByMXID(ctx, userID)
- if err != nil {
- br.ZLog.Err(err).Stringer("mxid", userID).Msg("Failed to get user by MXID from database")
- return nil
- }
- return br.loadDBUser(ctx, dbUser, userIDPtr)
- }
- return user
-}
-
-func (br *WABridge) GetUserByMXID(userID id.UserID) *User {
- return br.getUserByMXID(userID, false)
-}
-
-func (br *WABridge) GetIUser(userID id.UserID, create bool) bridge.User {
- u := br.getUserByMXID(userID, !create)
- if u == nil {
- return nil
- }
- return u
-}
-
-func (user *User) GetPermissionLevel() bridgeconfig.PermissionLevel {
- return user.PermissionLevel
-}
-
-func (user *User) GetManagementRoomID() id.RoomID {
- return user.ManagementRoom
-}
-
-func (user *User) GetMXID() id.UserID {
- return user.MXID
-}
-
-func (user *User) GetCommandState() map[string]interface{} {
- return nil
-}
-
-func (br *WABridge) GetUserByMXIDIfExists(userID id.UserID) *User {
- return br.getUserByMXID(userID, true)
-}
-
-func (br *WABridge) GetUserByJID(jid types.JID) *User {
- br.usersLock.Lock()
- defer br.usersLock.Unlock()
- user, ok := br.usersByUsername[jid.User]
- if !ok {
- ctx := context.TODO()
- dbUser, err := br.DB.User.GetByUsername(ctx, jid.User)
- if err != nil {
- br.ZLog.Err(err).Stringer("jid", jid).Msg("Failed to get user by JID from database")
- return nil
- }
- return br.loadDBUser(ctx, dbUser, nil)
- }
- return user
-}
-
-func (user *User) addToJIDMap() {
- user.bridge.usersLock.Lock()
- user.bridge.usersByUsername[user.JID.User] = user
- user.bridge.usersLock.Unlock()
-}
-
-func (user *User) removeFromJIDMap(state status.BridgeState) {
- user.bridge.usersLock.Lock()
- jidUser, ok := user.bridge.usersByUsername[user.JID.User]
- if ok && user == jidUser {
- delete(user.bridge.usersByUsername, user.JID.User)
- }
- user.bridge.usersLock.Unlock()
- user.bridge.Metrics.TrackLoginState(user.JID, false)
- user.BridgeState.Send(state)
-}
-
-func (br *WABridge) GetAllUsers() []*User {
- br.usersLock.Lock()
- defer br.usersLock.Unlock()
- ctx := context.TODO()
- dbUsers, err := br.DB.User.GetAll(ctx)
- if err != nil {
- br.ZLog.Error().Err(err).Msg("Failed to get all users from database")
- return nil
- }
- output := make([]*User, len(dbUsers))
- for index, dbUser := range dbUsers {
- user, ok := br.usersByMXID[dbUser.MXID]
- if !ok {
- user = br.loadDBUser(ctx, dbUser, nil)
- }
- output[index] = user
- }
- return output
-}
-
-func (br *WABridge) loadDBUser(ctx context.Context, dbUser *database.User, mxid *id.UserID) *User {
- if dbUser == nil {
- if mxid == nil {
- return nil
- }
- dbUser = br.DB.User.New()
- dbUser.MXID = *mxid
- err := dbUser.Insert(ctx)
- if err != nil {
- br.ZLog.Error().Err(err).Msg("Failed to insert new user into database")
- return nil
- }
- }
- user := br.NewUser(dbUser)
- br.usersByMXID[user.MXID] = user
- if !user.JID.IsEmpty() {
- var err error
- user.Session, err = br.WAContainer.GetDevice(user.JID)
- if err != nil {
- user.zlog.Err(err).Msg("Failed to load user's whatsapp session")
- } else if user.Session == nil {
- user.zlog.Warn().Stringer("jid", user.JID).Msg("Didn't find session data for user's JID, treating user as logged out")
- user.JID = types.EmptyJID
- err = user.Update(ctx)
- if err != nil {
- user.zlog.Err(err).Msg("Failed to save user after clearing JID")
- }
- } else {
- user.Session.Log = waLog.Zerolog(user.zlog.With().Str("component", "whatsmeow").Str("db_section", "whatsmeow").Logger())
- br.usersByUsername[user.JID.User] = user
- }
- }
- if len(user.ManagementRoom) > 0 {
- br.managementRooms[user.ManagementRoom] = user
- }
- return user
-}
-
-func (br *WABridge) NewUser(dbUser *database.User) *User {
- user := &User{
- User: dbUser,
- bridge: br,
- zlog: br.ZLog.With().Str("user_id", dbUser.MXID.String()).Logger(),
-
- historySyncs: make(chan *events.HistorySync, 32),
- lastPresence: types.PresenceUnavailable,
-
- resyncQueue: make(map[types.JID]resyncQueueItem),
-
- mediaRetryLock: semaphore.NewWeighted(br.Config.Bridge.HistorySync.MediaRequests.MaxAsyncHandle),
- }
-
- user.PermissionLevel = user.bridge.Config.Bridge.Permissions.Get(user.MXID)
- user.RelayWhitelisted = user.PermissionLevel >= bridgeconfig.PermissionLevelRelay
- user.Whitelisted = user.PermissionLevel >= bridgeconfig.PermissionLevelUser
- user.Admin = user.PermissionLevel >= bridgeconfig.PermissionLevelAdmin
- user.BridgeState = br.NewBridgeStateQueue(user)
- user.enqueueBackfillsTimer = time.NewTimer(5 * time.Second)
- user.enqueueBackfillsTimer.Stop()
- go user.puppetResyncLoop()
- return user
-}
-
-const resyncMinInterval = 7 * 24 * time.Hour
-const resyncLoopInterval = 4 * time.Hour
-
-func (user *User) puppetResyncLoop() {
- user.nextResync = time.Now().Add(resyncLoopInterval).Add(-time.Duration(rand.Intn(3600)) * time.Second)
- for {
- time.Sleep(user.nextResync.Sub(time.Now()))
- user.nextResync = time.Now().Add(resyncLoopInterval)
- user.doPuppetResync()
- }
-}
-
-func (user *User) EnqueuePuppetResync(puppet *Puppet) {
- if puppet.LastSync.Add(resyncMinInterval).After(time.Now()) {
- return
- }
- user.resyncQueueLock.Lock()
- if _, exists := user.resyncQueue[puppet.JID]; !exists {
- user.resyncQueue[puppet.JID] = resyncQueueItem{puppet: puppet}
- user.zlog.Debug().
- Stringer("jid", puppet.JID).
- Str("next_resync", time.Until(user.nextResync).String()).
- Msg("Enqueued resync for puppet")
- }
- user.resyncQueueLock.Unlock()
-}
-
-func (user *User) EnqueuePortalResync(portal *Portal) {
- if !portal.IsGroupChat() || portal.LastSync.Add(resyncMinInterval).After(time.Now()) {
- return
- }
- user.resyncQueueLock.Lock()
- if _, exists := user.resyncQueue[portal.Key.JID]; !exists {
- user.resyncQueue[portal.Key.JID] = resyncQueueItem{portal: portal}
- user.zlog.Debug().
- Stringer("jid", portal.Key.JID).
- Str("next_resync", time.Until(user.nextResync).String()).
- Msg("Enqueued resync for portal")
- }
- user.resyncQueueLock.Unlock()
-}
-
-func (user *User) doPuppetResync() {
- if !user.IsLoggedIn() {
- return
- }
- user.resyncQueueLock.Lock()
- if len(user.resyncQueue) == 0 {
- user.resyncQueueLock.Unlock()
- return
- }
- log := user.zlog.With().Str("action", "puppet resync").Logger()
- ctx := log.WithContext(context.TODO())
- queue := user.resyncQueue
- user.resyncQueue = make(map[types.JID]resyncQueueItem)
- user.resyncQueueLock.Unlock()
- var puppetJIDs []types.JID
- var puppets []*Puppet
- var portals []*Portal
- for jid, item := range queue {
- var lastSync time.Time
- if item.puppet != nil {
- lastSync = item.puppet.LastSync
- } else if item.portal != nil {
- lastSync = item.portal.LastSync
- }
- if lastSync.Add(resyncMinInterval).After(time.Now()) {
- log.Debug().
- Stringer("jid", jid).
- Str("last_sync", time.Since(lastSync).String()).
- Msg("Not resyncing, last sync was too recent")
- continue
- }
- if item.puppet != nil {
- puppets = append(puppets, item.puppet)
- puppetJIDs = append(puppetJIDs, jid)
- } else if item.portal != nil {
- portals = append(portals, item.portal)
- }
- }
- for _, portal := range portals {
- groupInfo, err := user.Client.GetGroupInfo(portal.Key.JID)
- if err != nil {
- log.Warn().Err(err).Stringer("jid", portal.Key.JID).Msg("Failed to get group info for background sync")
- } else {
- log.Debug().Stringer("jid", portal.Key.JID).Msg("Doing background sync for group")
- portal.UpdateMatrixRoom(ctx, user, groupInfo, nil)
- }
- }
- if len(puppetJIDs) == 0 {
- return
- }
- log.Debug().Array("jids", exzerolog.ArrayOfStringers(puppetJIDs)).Msg("Doing background sync for users")
- infos, err := user.Client.GetUserInfo(puppetJIDs)
- if err != nil {
- log.Err(err).Msg("Failed to get user info for background sync")
- return
- }
- for _, puppet := range puppets {
- info, ok := infos[puppet.JID]
- if !ok {
- log.Warn().Stringer("jid", puppet.JID).Msg("Didn't get info for puppet in background sync")
- continue
- }
- var contactPtr *types.ContactInfo
- contact, err := user.Session.Contacts.GetContact(puppet.JID)
- if err != nil {
- log.Err(err).Stringer("jid", puppet.JID).Msg("Failed to get contact info for puppet in background sync")
- } else if contact.Found {
- contactPtr = &contact
- }
- puppet.Sync(ctx, user, contactPtr, info.PictureID != "" && info.PictureID != puppet.Avatar, true)
- }
-}
-
-func (user *User) ensureInvited(ctx context.Context, intent *appservice.IntentAPI, roomID id.RoomID, isDirect bool) (ok bool) {
- extraContent := make(map[string]interface{})
- if isDirect {
- extraContent["is_direct"] = true
- }
- customPuppet := user.bridge.GetPuppetByCustomMXID(user.MXID)
- if customPuppet != nil && customPuppet.CustomIntent() != nil {
- extraContent["fi.mau.will_auto_accept"] = true
- }
- _, err := intent.InviteUser(ctx, roomID, &mautrix.ReqInviteUser{UserID: user.MXID}, extraContent)
- var httpErr mautrix.HTTPError
- if err != nil && errors.As(err, &httpErr) && httpErr.RespError != nil && strings.Contains(httpErr.RespError.Err, "is already in the room") {
- err = user.bridge.StateStore.SetMembership(ctx, roomID, user.MXID, event.MembershipJoin)
- if err != nil {
- user.zlog.Err(err).Stringer("room_id", roomID).Msg("Failed to update membership to join in state store after invite failed")
- }
- ok = true
- return
- } else if err != nil {
- user.zlog.Err(err).Stringer("room_id", roomID).Msg("Failed to invite user to room")
- } else {
- ok = true
- }
-
- if customPuppet != nil && customPuppet.CustomIntent() != nil {
- err = customPuppet.CustomIntent().EnsureJoined(ctx, roomID, appservice.EnsureJoinedParams{IgnoreCache: true})
- if err != nil {
- user.zlog.Err(err).Stringer("room_id", roomID).Msg("Failed to auto-join room")
- ok = false
- } else {
- ok = true
- }
- }
- return
-}
-
-func (user *User) GetSpaceRoom(ctx context.Context) id.RoomID {
- if !user.bridge.Config.Bridge.PersonalFilteringSpaces {
- return ""
- }
-
- if len(user.SpaceRoom) == 0 {
- user.spaceCreateLock.Lock()
- defer user.spaceCreateLock.Unlock()
- if len(user.SpaceRoom) > 0 {
- return user.SpaceRoom
- }
-
- resp, err := user.bridge.Bot.CreateRoom(ctx, &mautrix.ReqCreateRoom{
- Visibility: "private",
- Name: "WhatsApp",
- Topic: "Your WhatsApp bridged chats",
- InitialState: []*event.Event{{
- Type: event.StateRoomAvatar,
- Content: event.Content{
- Parsed: &event.RoomAvatarEventContent{
- URL: user.bridge.Config.AppService.Bot.ParsedAvatar.CUString(),
- },
- },
- }},
- CreationContent: map[string]interface{}{
- "type": event.RoomTypeSpace,
- },
- PowerLevelOverride: &event.PowerLevelsEventContent{
- Users: map[id.UserID]int{
- user.bridge.Bot.UserID: 9001,
- user.MXID: 50,
- },
- },
- })
-
- if err != nil {
- user.zlog.Err(err).Msg("Failed to auto-create space room")
- } else {
- user.SpaceRoom = resp.RoomID
- err = user.Update(ctx)
- if err != nil {
- user.zlog.Err(err).Msg("Failed to save user after creating space room")
- }
- user.ensureInvited(ctx, user.bridge.Bot, user.SpaceRoom, false)
- }
- } else if !user.spaceMembershipChecked && !user.bridge.StateStore.IsInRoom(ctx, user.SpaceRoom, user.MXID) {
- user.ensureInvited(ctx, user.bridge.Bot, user.SpaceRoom, false)
- }
- user.spaceMembershipChecked = true
-
- return user.SpaceRoom
-}
-
-func (user *User) GetManagementRoom(ctx context.Context) id.RoomID {
- if len(user.ManagementRoom) == 0 {
- user.mgmtCreateLock.Lock()
- defer user.mgmtCreateLock.Unlock()
- if len(user.ManagementRoom) > 0 {
- return user.ManagementRoom
- }
- creationContent := make(map[string]interface{})
- if !user.bridge.Config.Bridge.FederateRooms {
- creationContent["m.federate"] = false
- }
- resp, err := user.bridge.Bot.CreateRoom(ctx, &mautrix.ReqCreateRoom{
- Topic: "WhatsApp bridge notices",
- IsDirect: true,
- CreationContent: creationContent,
- })
- if err != nil {
- user.zlog.Err(err).Msg("Failed to auto-create management room")
- } else {
- user.SetManagementRoom(resp.RoomID)
- }
- }
- return user.ManagementRoom
-}
-
-func (user *User) SetManagementRoom(roomID id.RoomID) {
- ctx := context.TODO()
-
- existingUser, ok := user.bridge.managementRooms[roomID]
- if ok {
- existingUser.ManagementRoom = ""
- err := existingUser.Update(ctx)
- if err != nil {
- user.zlog.Err(err).
- Stringer("other_user_mxid", existingUser.MXID).
- Msg("Failed to save previous user after removing from old management room")
- }
- }
-
- user.ManagementRoom = roomID
- user.bridge.managementRooms[user.ManagementRoom] = user
- err := user.Update(ctx)
- if err != nil {
- user.zlog.Err(err).Msg("Failed to save user after setting management room")
- }
-}
-
-var ErrAlreadyLoggedIn = errors.New("already logged in")
-
-func (user *User) obfuscateJID(jid types.JID) string {
- // Turn the first 4 bytes of HMAC-SHA256(hs_token, 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(user.bridge.Config.AppService.HSToken)).Sum([]byte(jid.User))[:4])
- return fmt.Sprintf("+%s-%d-%s:%d", jid.User[:1], randomNumber, jid.User[len(jid.User)-2:], jid.Device)
-}
-
-func (user *User) createClient(sess *store.Device) {
- user.Client = whatsmeow.NewClient(sess, waLog.Zerolog(user.zlog.With().Str("component", "whatsmeow").Logger()))
- user.qrReceived = make(chan struct{})
- user.qrWaiting.Store(true)
- user.Client.AddEventHandler(user.HandleEvent)
- user.Client.SetForceActiveDeliveryReceipts(user.bridge.Config.Bridge.ForceActiveDeliveryReceipts)
- user.Client.AutomaticMessageRerequestFromPhone = true
- user.Client.GetMessageForRetry = func(requester, to types.JID, id types.MessageID) *waProto.Message {
- Analytics.Track(user.MXID, "WhatsApp incoming retry (message not found)", map[string]interface{}{
- "requester": user.obfuscateJID(requester),
- "messageID": id,
- })
- user.bridge.Metrics.TrackRetryReceipt(0, false)
- return nil
- }
- user.Client.PreRetryCallback = func(receipt *events.Receipt, messageID types.MessageID, retryCount int, msg *waProto.Message) bool {
- Analytics.Track(user.MXID, "WhatsApp incoming retry (accepted)", map[string]interface{}{
- "requester": user.obfuscateJID(receipt.Sender),
- "messageID": messageID,
- "retryCount": retryCount,
- })
- user.bridge.Metrics.TrackRetryReceipt(retryCount, true)
- return true
- }
- if !user.bridge.Config.WhatsApp.ProxyOnlyLogin || sess.ID == nil {
- if proxy, err := user.getProxy("login"); err != nil {
- user.zlog.Err(err).Msg("Failed to get proxy address")
- } else if err = user.Client.SetProxyAddress(proxy, whatsmeow.SetProxyOptions{
- NoMedia: user.bridge.Config.WhatsApp.ProxyOnlyLogin,
- }); err != nil {
- user.zlog.Err(err).Msg("Failed to set proxy address")
- }
- }
- if user.bridge.Config.WhatsApp.ProxyOnlyLogin {
- user.Client.ToggleProxyOnlyForLogin(true)
- }
-}
-
-type respGetProxy struct {
- ProxyURL string `json:"proxy_url"`
-}
-
-func (user *User) getProxy(reason string) (string, error) {
- if user.bridge.Config.WhatsApp.GetProxyURL == "" {
- return user.bridge.Config.WhatsApp.Proxy, nil
- }
- parsed, err := url.Parse(user.bridge.Config.WhatsApp.GetProxyURL)
- if err != nil {
- return "", fmt.Errorf("failed to parse address: %w", err)
- }
- q := parsed.Query()
- q.Set("reason", reason)
- parsed.RawQuery = q.Encode()
- req, err := http.NewRequest(http.MethodGet, parsed.String(), nil)
- if err != nil {
- return "", fmt.Errorf("failed to prepare request: %w", err)
- }
- req.Header.Set("User-Agent", mautrix.DefaultUserAgent)
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- return "", fmt.Errorf("failed to send request: %w", err)
- } else if resp.StatusCode >= 300 || resp.StatusCode < 200 {
- return "", fmt.Errorf("unexpected status code %d", resp.StatusCode)
- }
- var respData respGetProxy
- err = json.NewDecoder(resp.Body).Decode(&respData)
- if err != nil {
- return "", fmt.Errorf("failed to decode response: %w", err)
- }
- return respData.ProxyURL, nil
-}
-
-func (user *User) Login(ctx context.Context) (<-chan whatsmeow.QRChannelItem, chan struct{}, error) {
- user.connLock.Lock()
- defer user.connLock.Unlock()
- if user.Session != nil {
- return nil, nil, ErrAlreadyLoggedIn
- } else if user.Client != nil {
- user.unlockedDeleteConnection()
- }
- newSession := user.bridge.WAContainer.NewDevice()
- newSession.Log = waLog.Zerolog(user.zlog.With().Str("component", "whatsmeow session").Logger())
- user.createClient(newSession)
- qrChan, err := user.Client.GetQRChannel(ctx)
- if err != nil {
- return nil, nil, fmt.Errorf("failed to get QR channel: %w", err)
- }
- err = user.Client.Connect()
- if err != nil {
- return nil, nil, fmt.Errorf("failed to connect to WhatsApp: %w", err)
- }
- return qrChan, user.qrReceived, nil
-}
-
-func (user *User) Connect() bool {
- user.connLock.Lock()
- defer user.connLock.Unlock()
- if user.Client != nil {
- return user.Client.IsConnected()
- } else if user.Session == nil {
- return false
- }
- user.zlog.Debug().Msg("Connecting to WhatsApp")
- user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting, Error: WAConnecting})
- user.createClient(user.Session)
- err := user.Client.Connect()
- if err != nil {
- user.zlog.Err(err).Msg("Error connecting to WhatsApp")
- user.BridgeState.Send(status.BridgeState{
- StateEvent: status.StateUnknownError,
- Error: WAConnectionFailed,
- Info: map[string]interface{}{
- "go_error": err.Error(),
- },
- })
- return false
- }
- return true
-}
-
-func (user *User) unlockedDeleteConnection() {
- if user.Client == nil {
- return
- }
- user.Client.Disconnect()
- user.Client.RemoveEventHandlers()
- user.Client = nil
- user.bridge.Metrics.TrackConnectionState(user.JID, false)
-}
-
-func (user *User) DeleteConnection() {
- user.connLock.Lock()
- defer user.connLock.Unlock()
- user.unlockedDeleteConnection()
-}
-
-func (user *User) HasSession() bool {
- return user.Session != nil
-}
-
-func (user *User) DeleteSession(ctx context.Context) {
- log := zerolog.Ctx(ctx)
- if user.Session != nil {
- err := user.Session.Delete()
- if err != nil {
- log.Err(err).Msg("Failed to delete session")
- }
- user.Session = nil
- }
- if !user.JID.IsEmpty() {
- user.JID = types.EmptyJID
- err := user.Update(ctx)
- if err != nil {
- log.Err(err).Msg("Failed to save user after clearing JID")
- }
- }
-
- // Delete all of the backfill and history sync data.
- err := user.bridge.DB.BackfillQueue.DeleteAll(ctx, user.MXID)
- if err != nil {
- log.Err(err).Msg("Failed to delete backfill queue data")
- }
- err = user.bridge.DB.HistorySync.DeleteAllConversations(ctx, user.MXID)
- if err != nil {
- log.Err(err).Msg("Failed to delete historical conversation list")
- }
- err = user.bridge.DB.HistorySync.DeleteAllMessages(ctx, user.MXID)
- if err != nil {
- log.Err(err).Msg("Failed to delete historical messages")
- }
- err = user.bridge.DB.MediaBackfillRequest.DeleteAllMediaBackfillRequests(ctx, user.MXID)
- if err != nil {
- log.Err(err).Msg("Failed to delete media backfill requests")
- }
-}
-
-func (user *User) IsConnected() bool {
- return user.Client != nil && user.Client.IsConnected()
-}
-
-func (user *User) IsLoggedIn() bool {
- return user.IsConnected() && user.Client.IsLoggedIn()
-}
-
-func (user *User) sendMarkdownBridgeAlert(ctx context.Context, formatString string, args ...interface{}) {
- if user.bridge.Config.Bridge.DisableBridgeAlerts {
- return
- }
- notice := fmt.Sprintf(formatString, args...)
- content := format.RenderMarkdown(notice, true, false)
- _, err := user.bridge.Bot.SendMessageEvent(ctx, user.GetManagementRoom(ctx), event.EventMessage, content)
- if err != nil {
- user.zlog.Warn().Err(err).Str("notice", notice).Msg("Failed to send bridge alert")
- }
-}
-
-const callEventMaxAge = 15 * time.Minute
-
-func (user *User) handleCallStart(sender types.JID, id, callType string, ts time.Time) {
- if !user.bridge.Config.Bridge.CallStartNotices || ts.Add(callEventMaxAge).Before(time.Now()) {
- return
- }
- portal := user.GetPortalByJID(sender)
- text := "Incoming call. Use the WhatsApp app to answer."
- if callType != "" {
- text = fmt.Sprintf("Incoming %s call. Use the WhatsApp app to answer.", callType)
- }
- portal.events <- &PortalEvent{
- Message: &PortalMessage{
- fake: &fakeMessage{
- Sender: sender,
- Text: text,
- ID: id,
- Time: ts,
- Important: true,
- },
- source: user,
- },
- }
-}
-
-const PhoneDisconnectWarningTime = 12 * 24 * time.Hour // 12 days
-const PhoneDisconnectPingTime = 10 * 24 * time.Hour
-const PhoneMinPingInterval = 24 * time.Hour
-
-func (user *User) sendHackyPhonePing(ctx context.Context) {
- user.PhoneLastPinged = time.Now()
- msgID := user.Client.GenerateMessageID()
- keyIDs := make([]*waProto.AppStateSyncKeyId, 0, 1)
- lastKeyID, err := user.GetLastAppStateKeyID(ctx)
- if lastKeyID != nil {
- keyIDs = append(keyIDs, &waProto.AppStateSyncKeyId{
- KeyID: lastKeyID,
- })
- } else {
- user.zlog.Warn().Err(err).Msg("Failed to get last app state key ID to send hacky phone ping - sending empty request")
- }
- resp, err := user.Client.SendMessage(ctx, user.JID.ToNonAD(), &waProto.Message{
- ProtocolMessage: &waProto.ProtocolMessage{
- Type: waProto.ProtocolMessage_APP_STATE_SYNC_KEY_REQUEST.Enum(),
- AppStateSyncKeyRequest: &waProto.AppStateSyncKeyRequest{
- KeyIDs: keyIDs,
- },
- },
- }, whatsmeow.SendRequestExtra{Peer: true, ID: msgID})
- if err != nil {
- user.zlog.Err(err).Msg("Failed to send hacky phone ping")
- } else {
- user.zlog.Debug().
- Str("message_id", msgID).
- Int64("message_ts", resp.Timestamp.Unix()).
- Msg("Sent hacky phone ping because phone has been offline for >10 days")
- user.PhoneLastPinged = resp.Timestamp
- err = user.Update(ctx)
- if err != nil {
- user.zlog.Err(err).Msg("Failed to save user after sending hacky phone ping")
- }
- }
-}
-
-func (user *User) PhoneRecentlySeen(doPing bool) bool {
- if doPing && !user.PhoneLastSeen.IsZero() && user.PhoneLastSeen.Add(PhoneDisconnectPingTime).Before(time.Now()) && user.PhoneLastPinged.Add(PhoneMinPingInterval).Before(time.Now()) {
- // Over 10 days since the phone was seen and over a day since the last somewhat hacky ping, send a new ping.
- go user.sendHackyPhonePing(context.TODO())
- }
- return user.PhoneLastSeen.IsZero() || user.PhoneLastSeen.Add(PhoneDisconnectWarningTime).After(time.Now())
-}
-
-// phoneSeen records a timestamp when the user's main device was seen online.
-// The stored timestamp can later be used to warn the user if the main device is offline for too long.
-func (user *User) phoneSeen(ts time.Time) {
- if user.PhoneLastSeen.Add(1 * time.Hour).After(ts) {
- // 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
- } else if !user.PhoneRecentlySeen(false) {
- if user.BridgeState.GetPrev().Error == WAPhoneOffline && user.IsConnected() {
- user.zlog.Debug().Msg("Saw phone after current bridge state said it has been offline, switching state back to connected")
- user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
- } else {
- user.zlog.Debug().
- Bool("is_connected", user.IsConnected()).
- Str("prev_error", string(user.BridgeState.GetPrev().Error)).
- Msg("Saw phone after current bridge state said it has been offline, not sending new bridge state")
- }
- }
- user.PhoneLastSeen = ts
- go func() {
- err := user.Update(context.TODO())
- if err != nil {
- user.zlog.Err(err).Msg("Failed to save user after updating phone last seen")
- }
- }()
-}
-
-func formatDisconnectTime(dur time.Duration) string {
- days := int(math.Floor(dur.Hours() / 24))
- hours := int(dur.Hours()) % 24
- if hours == 0 {
- return fmt.Sprintf("%d days", days)
- } else if hours == 1 {
- return fmt.Sprintf("%d days and 1 hour", days)
- } else {
- return fmt.Sprintf("%d days and %d hours", days, hours)
- }
-}
-
-func (user *User) sendPhoneOfflineWarning(ctx context.Context) {
- if user.lastPhoneOfflineWarning.Add(12 * time.Hour).After(time.Now()) {
- // Don't spam the warning too much
- return
- }
- user.lastPhoneOfflineWarning = time.Now()
- timeSinceSeen := time.Now().Sub(user.PhoneLastSeen)
- user.sendMarkdownBridgeAlert(ctx, "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.", formatDisconnectTime(timeSinceSeen))
-}
-
-func (user *User) HandleEvent(event interface{}) {
- ctx := user.zlog.With().
- Str("action", "handle whatsapp event").
- Type("wa_event_type", event).
- Logger().
- WithContext(context.TODO())
- switch v := event.(type) {
- case *events.LoggedOut:
- go user.handleLoggedOut(ctx, v.OnConnect, v.Reason)
- case *events.Connected:
- user.bridge.Metrics.TrackConnectionState(user.JID, true)
- user.bridge.Metrics.TrackLoginState(user.JID, true)
- if len(user.Client.Store.PushName) > 0 {
- go func() {
- err := user.Client.SendPresence(user.lastPresence)
- if err != nil {
- user.zlog.Warn().Err(err).Msg("Failed to send initial presence after connecting")
- }
- }()
- }
- go user.tryAutomaticDoublePuppeting()
-
- if user.bridge.Config.Bridge.HistorySync.Backfill && !user.historySyncLoopsStarted {
- go user.handleHistorySyncsLoop()
- user.historySyncLoopsStarted = true
- }
- case *events.OfflineSyncPreview:
- user.zlog.Info().
- Int("message_count", v.Messages).
- Int("receipt_count", v.Receipts).
- Int("notification_count", v.Notifications).
- Int("app_data_change_count", v.AppDataChanges).
- Msg("Server sent number of events that were missed during downtime")
- user.BridgeState.Send(status.BridgeState{
- StateEvent: status.StateBackfilling,
- Message: fmt.Sprintf("backfilling %d messages and %d receipts", v.Messages, v.Receipts),
- })
- case *events.OfflineSyncCompleted:
- if !user.PhoneRecentlySeen(true) {
- user.zlog.Info().
- Time("phone_last_seen", user.PhoneLastSeen).
- Msg("Offline sync completed, but phone last seen date is still old - sending phone offline bridge status")
- user.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: WAPhoneOffline})
- } else {
- if user.BridgeState.GetPrev().StateEvent == status.StateBackfilling {
- user.zlog.Info().Msg("Offline sync completed")
- }
- user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
- }
- case *events.AppStateSyncComplete:
- if len(user.Client.Store.PushName) > 0 && v.Name == appstate.WAPatchCriticalBlock {
- err := user.Client.SendPresence(user.lastPresence)
- if err != nil {
- user.zlog.Warn().Err(err).Msg("Failed to send presence after app state sync")
- }
- } else if v.Name == appstate.WAPatchCriticalUnblockLow {
- go func() {
- err := user.ResyncContacts(false)
- if err != nil {
- user.zlog.Err(err).Msg("Failed to resync contacts after app state sync")
- }
- }()
- }
- 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 := user.Client.SendPresence(user.lastPresence)
- if err != nil {
- user.zlog.Warn().Err(err).Msg("Failed to send presence after push name update")
- }
- _, _, err = user.Client.Store.Contacts.PutPushName(user.JID.ToNonAD(), v.Action.GetName())
- if err != nil {
- user.zlog.Err(err).Msg("Failed to update push name in store")
- }
- go user.syncPuppet(user.JID.ToNonAD(), "push name setting")
- case *events.PairSuccess:
- user.PhoneLastSeen = time.Now()
- user.Session = user.Client.Store
- user.JID = v.ID
- user.addToJIDMap()
- err := user.Update(ctx)
- if err != nil {
- user.zlog.Err(err).Msg("Failed to save user after pair success")
- }
- case *events.StreamError:
- var message string
- if v.Code != "" {
- message = fmt.Sprintf("Unknown stream error with code %s", v.Code)
- } else if children := v.Raw.GetChildren(); len(children) > 0 {
- message = fmt.Sprintf("Unknown stream error (contains %s node)", children[0].Tag)
- } else {
- message = "Unknown stream error"
- }
- user.BridgeState.Send(status.BridgeState{StateEvent: status.StateUnknownError, Message: message})
- user.bridge.Metrics.TrackConnectionState(user.JID, false)
- case *events.StreamReplaced:
- if user.bridge.Config.Bridge.CrashOnStreamReplaced {
- user.zlog.Info().Msg("Stopping bridge due to StreamReplaced event")
- user.bridge.ManualStop(60)
- } else {
- user.BridgeState.Send(status.BridgeState{StateEvent: status.StateUnknownError, Message: "Stream replaced"})
- user.bridge.Metrics.TrackConnectionState(user.JID, false)
- user.sendMarkdownBridgeAlert(ctx, "The bridge was started in another location. Use `reconnect` to reconnect this one.")
- }
- case *events.ConnectFailure:
- user.BridgeState.Send(status.BridgeState{StateEvent: status.StateUnknownError, Message: fmt.Sprintf("Unknown connection failure: %s (%s)", v.Reason, v.Message)})
- user.bridge.Metrics.TrackConnectionState(user.JID, false)
- user.bridge.Metrics.TrackConnectionFailure(fmt.Sprintf("status-%d", v.Reason))
- case *events.ClientOutdated:
- user.zlog.Error().Msg("Got a client outdated connect failure. The bridge is likely out of date, please update immediately.")
- user.BridgeState.Send(status.BridgeState{StateEvent: status.StateUnknownError, Message: "Connect failure: 405 client outdated"})
- user.bridge.Metrics.TrackConnectionState(user.JID, false)
- user.bridge.Metrics.TrackConnectionFailure("client-outdated")
- case *events.TemporaryBan:
- user.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Message: v.String()})
- user.bridge.Metrics.TrackConnectionState(user.JID, false)
- user.bridge.Metrics.TrackConnectionFailure("temporary-ban")
- case *events.Disconnected:
- // Don't send the normal transient disconnect state if we're already in a different transient disconnect state.
- // TODO remove this if/when the phone offline state is moved to a sub-state of CONNECTED
- if user.BridgeState.GetPrev().Error != WAPhoneOffline && user.PhoneRecentlySeen(false) {
- user.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: WADisconnected})
- }
- user.bridge.Metrics.TrackConnectionState(user.JID, false)
- case *events.Contact:
- go user.syncPuppet(v.JID, "contact event")
- case *events.PushName:
- go user.syncPuppet(v.JID, "push name event")
- case *events.BusinessName:
- go user.syncPuppet(v.JID, "business name event")
- case *events.GroupInfo:
- user.groupListCache = nil
- go user.handleGroupUpdate(v)
- case *events.JoinedGroup:
- user.groupListCache = nil
- go user.handleGroupCreate(v)
- case *events.NewsletterJoin:
- go user.handleNewsletterJoin(v)
- case *events.NewsletterLeave:
- go user.handleNewsletterLeave(v)
- case *events.Picture:
- go user.handlePictureUpdate(ctx, v)
- case *events.Receipt:
- if v.IsFromMe && v.Sender.Device == 0 {
- user.phoneSeen(v.Timestamp)
- }
- go user.handleReceipt(v)
- case *events.ChatPresence:
- go user.handleChatPresence(ctx, v)
- case *events.Message:
- portal := user.GetPortalByMessageSource(v.Info.MessageSource)
- portal.events <- &PortalEvent{
- Message: &PortalMessage{evt: v, source: user},
- }
- case *events.MediaRetry:
- user.phoneSeen(v.Timestamp)
- portal := user.GetPortalByJID(v.ChatID)
- go portal.handleMediaRetry(v, user)
- case *events.CallOffer:
- user.handleCallStart(v.CallCreator, v.CallID, "", v.Timestamp)
- case *events.CallOfferNotice:
- user.handleCallStart(v.CallCreator, v.CallID, v.Type, v.Timestamp)
- case *events.IdentityChange:
- puppet := user.bridge.GetPuppetByJID(v.JID)
- if puppet == nil {
- return
- }
- portal := user.GetPortalByJID(v.JID)
- if len(portal.MXID) > 0 && user.bridge.Config.Bridge.IdentityChangeNotices {
- text := fmt.Sprintf("Your security code with %s changed.", puppet.Displayname)
- if v.Implicit {
- text = fmt.Sprintf("Your security code with %s (device #%d) changed.", puppet.Displayname, v.JID.Device)
- }
- portal.events <- &PortalEvent{
- Message: &PortalMessage{
- fake: &fakeMessage{
- Sender: v.JID,
- Text: text,
- ID: strconv.FormatInt(v.Timestamp.Unix(), 10),
- Time: v.Timestamp,
- Important: false,
- },
- source: user,
- },
- }
- }
- case *events.CallTerminate, *events.CallRelayLatency, *events.CallAccept, *events.UnknownCallEvent:
- // ignore
- case *events.UndecryptableMessage:
- portal := user.GetPortalByMessageSource(v.Info.MessageSource)
- portal.events <- &PortalEvent{
- Message: &PortalMessage{undecryptable: v, source: user},
- }
- case *events.HistorySync:
- if user.bridge.Config.Bridge.HistorySync.Backfill {
- user.historySyncs <- v
- }
- case *events.Mute:
- portal := user.GetPortalByJID(v.JID)
- if portal != nil {
- var mutedUntil time.Time
- if v.Action.GetMuted() {
- mutedUntil = time.Unix(v.Action.GetMuteEndTimestamp(), 0)
- }
- go user.updateChatMute(ctx, nil, portal, mutedUntil)
- }
- case *events.Archive:
- portal := user.GetPortalByJID(v.JID)
- if portal != nil {
- go user.updateChatTag(ctx, nil, portal, user.bridge.Config.Bridge.ArchiveTag, v.Action.GetArchived())
- }
- case *events.Pin:
- portal := user.GetPortalByJID(v.JID)
- if portal != nil {
- go user.updateChatTag(ctx, nil, portal, user.bridge.Config.Bridge.PinnedTag, v.Action.GetPinned())
- }
- case *events.AppState:
- // Ignore
- case *events.KeepAliveTimeout:
- user.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: WAKeepaliveTimeout})
- case *events.KeepAliveRestored:
- user.zlog.Info().Msg("Keepalive restored after timeouts, sending connected event")
- user.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
- case *events.MarkChatAsRead:
- if user.bridge.Config.Bridge.SyncManualMarkedUnread {
- user.markUnread(ctx, user.GetPortalByJID(v.JID), !v.Action.GetRead())
- }
- case *events.DeleteForMe:
- portal := user.GetPortalByJID(v.ChatJID)
- if portal != nil {
- portal.deleteForMe(ctx, user, v)
- }
- case *events.DeleteChat:
- portal := user.GetPortalByJID(v.JID)
- if portal != nil {
- portal.HandleWhatsAppDeleteChat(ctx, user)
- }
- case *events.QR:
- if user.qrWaiting.Swap(false) {
- close(user.qrReceived)
- }
- default:
- user.zlog.Debug().Type("event_type", v).Msg("Unknown type of event in HandleEvent")
- }
-}
-
-func (user *User) updateChatMute(ctx context.Context, intent *appservice.IntentAPI, portal *Portal, mutedUntil time.Time) {
- if len(portal.MXID) == 0 || !user.bridge.Config.Bridge.MuteBridging {
- return
- } else if intent == nil {
- doublePuppet := user.bridge.GetPuppetByCustomMXID(user.MXID)
- if doublePuppet == nil || doublePuppet.CustomIntent() == nil {
- return
- }
- intent = doublePuppet.CustomIntent()
- }
- var err error
- if mutedUntil.IsZero() && mutedUntil.Before(time.Now()) {
- user.zlog.Debug().
- Stringer("portal_mxid", portal.MXID).
- Time("muted_until", mutedUntil).
- Msg("Portal muted until time is in the past, unmuting")
- err = intent.DeletePushRule(ctx, "global", pushrules.RoomRule, string(portal.MXID))
- } else {
- user.zlog.Debug().
- Stringer("portal_mxid", portal.MXID).
- Time("muted_until", mutedUntil).
- Msg("Portal muted until time is in the future, muting")
- err = intent.PutPushRule(ctx, "global", pushrules.RoomRule, string(portal.MXID), &mautrix.ReqPutPushRule{
- Actions: []pushrules.PushActionType{pushrules.ActionDontNotify},
- })
- }
- if err != nil && !errors.Is(err, mautrix.MNotFound) {
- user.zlog.Err(err).Stringer("portal_mxid", portal.MXID).Msg("Failed to update push rule through double puppet")
- }
-}
-
-type CustomTagData struct {
- Order json.Number `json:"order"`
- DoublePuppet string `json:"fi.mau.double_puppet_source"`
-}
-
-type CustomTagEventContent struct {
- Tags map[event.RoomTag]CustomTagData `json:"tags"`
-}
-
-func (user *User) updateChatTag(ctx context.Context, intent *appservice.IntentAPI, portal *Portal, tag event.RoomTag, active bool) {
- if len(portal.MXID) == 0 || len(tag) == 0 {
- return
- } else if intent == nil {
- doublePuppet := user.bridge.GetPuppetByCustomMXID(user.MXID)
- if doublePuppet == nil || doublePuppet.CustomIntent() == nil {
- return
- }
- intent = doublePuppet.CustomIntent()
- }
- var existingTags CustomTagEventContent
- err := intent.GetTagsWithCustomData(ctx, portal.MXID, &existingTags)
- if err != nil && !errors.Is(err, mautrix.MNotFound) {
- user.zlog.Err(err).Stringer("portal_mxid", portal.MXID).Msg("Failed to get tags through double puppet")
- }
- currentTag, ok := existingTags.Tags[tag]
- if active && !ok {
- user.zlog.Debug().Stringer("portal_mxid", portal.MXID).Str("tag", string(tag)).Msg("Adding tag to portal")
- data := CustomTagData{Order: "0.5", DoublePuppet: user.bridge.Name}
- err = intent.AddTagWithCustomData(ctx, portal.MXID, tag, &data)
- } else if !active && ok && currentTag.DoublePuppet == user.bridge.Name {
- user.zlog.Debug().Stringer("portal_mxid", portal.MXID).Str("tag", string(tag)).Msg("Removing tag from portal")
- err = intent.RemoveTag(ctx, portal.MXID, tag)
- } else {
- err = nil
- }
- if err != nil {
- user.zlog.Err(err).Stringer("portal_mxid", portal.MXID).Str("tag", string(tag)).Msg("Failed to update tag through double puppet")
- }
-}
-
-type CustomReadReceipt struct {
- Timestamp int64 `json:"ts,omitempty"`
- DoublePuppetSource string `json:"fi.mau.double_puppet_source,omitempty"`
-}
-
-type CustomReadMarkers struct {
- mautrix.ReqSetReadMarkers
- ReadExtra CustomReadReceipt `json:"com.beeper.read.extra"`
- FullyReadExtra CustomReadReceipt `json:"com.beeper.fully_read.extra"`
-}
-
-func (user *User) syncChatDoublePuppetDetails(ctx context.Context, portal *Portal, justCreated bool) {
- doublePuppet := portal.bridge.GetPuppetByCustomMXID(user.MXID)
- if doublePuppet == nil {
- return
- }
- if doublePuppet == nil || doublePuppet.CustomIntent() == nil || len(portal.MXID) == 0 {
- return
- }
- if justCreated || !user.bridge.Config.Bridge.TagOnlyOnCreate {
- chat, err := user.Client.Store.ChatSettings.GetChatSettings(portal.Key.JID)
- if err != nil {
- user.zlog.Err(err).Stringer("portal_jid", portal.Key.JID).Msg("Failed to get chat settings from store")
- return
- }
- intent := doublePuppet.CustomIntent()
- if portal.Key.JID == types.StatusBroadcastJID && justCreated {
- if user.bridge.Config.Bridge.MuteStatusBroadcast {
- user.updateChatMute(ctx, intent, portal, time.Now().Add(365*24*time.Hour))
- }
- if len(user.bridge.Config.Bridge.StatusBroadcastTag) > 0 {
- user.updateChatTag(ctx, intent, portal, user.bridge.Config.Bridge.StatusBroadcastTag, true)
- }
- return
- } else if !chat.Found {
- return
- }
- user.updateChatMute(ctx, intent, portal, chat.MutedUntil)
- user.updateChatTag(ctx, intent, portal, user.bridge.Config.Bridge.ArchiveTag, chat.Archived)
- user.updateChatTag(ctx, intent, portal, user.bridge.Config.Bridge.PinnedTag, chat.Pinned)
- }
-}
-
-func (user *User) getDirectChats(ctx context.Context) map[id.UserID][]id.RoomID {
- res := make(map[id.UserID][]id.RoomID)
- privateChats, err := user.bridge.DB.Portal.FindPrivateChats(ctx, user.JID.ToNonAD())
- if err != nil {
- user.zlog.Err(err).Msg("Failed to get private chats of user")
- return res
- }
- for _, portal := range privateChats {
- if len(portal.MXID) > 0 {
- res[user.bridge.FormatPuppetMXID(portal.Key.JID)] = []id.RoomID{portal.MXID}
- }
- }
- return res
-}
-
-func (user *User) UpdateDirectChats(ctx context.Context, chats map[id.UserID][]id.RoomID) {
- if !user.bridge.Config.Bridge.SyncDirectChatList {
- return
- }
- puppet := user.bridge.GetPuppetByCustomMXID(user.MXID)
- if puppet == nil || puppet.CustomIntent() == nil {
- return
- }
- intent := puppet.CustomIntent()
- method := http.MethodPatch
- if chats == nil {
- chats = user.getDirectChats(ctx)
- method = http.MethodPut
- }
- user.zlog.Debug().Msg("Updating m.direct list on homeserver")
- var err error
- if user.bridge.Config.Homeserver.Software == bridgeconfig.SoftwareAsmux {
- urlPath := intent.BuildClientURL("unstable", "com.beeper.asmux", "dms")
- _, err = intent.MakeFullRequest(ctx, mautrix.FullRequest{
- Method: method,
- URL: urlPath,
- Headers: http.Header{"X-Asmux-Auth": {user.bridge.AS.Registration.AppToken}},
- RequestJSON: chats,
- })
- } else {
- existingChats := make(map[id.UserID][]id.RoomID)
- err = intent.GetAccountData(ctx, event.AccountDataDirectChats.Type, &existingChats)
- if err != nil {
- user.zlog.Err(err).Msg("Failed to get m.direct list to update it")
- return
- }
- for userID, rooms := range existingChats {
- if _, ok := user.bridge.ParsePuppetMXID(userID); !ok {
- // This is not a ghost user, include it in the new list
- chats[userID] = rooms
- } else if _, ok := chats[userID]; !ok && method == http.MethodPatch {
- // This is a ghost user, but we're not replacing the whole list, so include it too
- chats[userID] = rooms
- }
- }
- err = intent.SetAccountData(ctx, event.AccountDataDirectChats.Type, &chats)
- }
- if err != nil {
- user.zlog.Err(err).Msg("Failed to update m.direct list")
- }
-}
-
-func (user *User) handleLoggedOut(ctx context.Context, onConnect bool, reason events.ConnectFailureReason) {
- errorCode := WAUnknownLogout
- if reason == events.ConnectFailureLoggedOut {
- errorCode = WALoggedOut
- } else if reason == events.ConnectFailureMainDeviceGone {
- errorCode = WAMainDeviceGone
- }
- user.removeFromJIDMap(status.BridgeState{StateEvent: status.StateBadCredentials, Error: errorCode})
- user.DeleteConnection()
- user.Session = nil
- user.JID = types.EmptyJID
- err := user.Update(ctx)
- if err != nil {
- user.zlog.Err(err).Msg("Failed to save user after getting logged out")
- }
- if onConnect {
- user.sendMarkdownBridgeAlert(ctx, "Connecting to WhatsApp failed as the device was unlinked (error %s). Please link the bridge to your phone again.", reason)
- } else {
- user.sendMarkdownBridgeAlert(ctx, "You were logged out from another device. Please link the bridge to your phone again.")
- }
-}
-
-func (user *User) GetPortalByMessageSource(ms types.MessageSource) *Portal {
- jid := ms.Chat
- if ms.IsIncomingBroadcast() {
- if ms.IsFromMe {
- jid = ms.BroadcastListOwner.ToNonAD()
- } else {
- jid = ms.Sender.ToNonAD()
- }
- if jid.IsEmpty() {
- return nil
- }
- }
- return user.bridge.GetPortalByJID(database.NewPortalKey(jid, user.JID))
-}
-
-func (user *User) GetPortalByJID(jid types.JID) *Portal {
- return user.bridge.GetPortalByJID(database.NewPortalKey(jid, user.JID))
-}
-
-func (user *User) syncPuppet(jid types.JID, reason string) {
- user.bridge.GetPuppetByJID(jid).SyncContact(user.zlog.WithContext(context.TODO()), user, false, false, reason)
-}
-
-func (user *User) ResyncContacts(forceAvatarSync bool) error {
- contacts, err := user.Client.Store.Contacts.GetAllContacts()
- if err != nil {
- return fmt.Errorf("failed to get cached contacts: %w", err)
- }
- user.zlog.Info().Int("contact_count", len(contacts)).Msg("Resyncing displaynames with contact info")
- ctx := user.zlog.With().Str("action", "resync contacts").Logger().WithContext(context.TODO())
- for jid, contact := range contacts {
- puppet := user.bridge.GetPuppetByJID(jid)
- if puppet != nil {
- puppet.Sync(ctx, user, &contact, forceAvatarSync, true)
- } else {
- user.zlog.Warn().Stringer("jid", jid).Msg("Got a nil puppet while syncing contacts")
- }
- }
- return nil
-}
-
-func (user *User) ResyncGroups(createPortals bool) error {
- groups, err := user.Client.GetJoinedGroups()
- if err != nil {
- return fmt.Errorf("failed to get group list from server: %w", err)
- }
- user.groupListCacheLock.Lock()
- user.groupListCache = groups
- user.groupListCacheTime = time.Now()
- user.groupListCacheLock.Unlock()
- ctx := user.zlog.With().Str("method", "ResyncGroups").Logger().WithContext(context.TODO())
- for _, group := range groups {
- portal := user.GetPortalByJID(group.JID)
- if len(portal.MXID) == 0 {
- if createPortals {
- err = portal.CreateMatrixRoom(ctx, user, group, nil, true, true)
- if err != nil {
- return fmt.Errorf("failed to create room for %s: %w", group.JID, err)
- }
- }
- } else {
- portal.UpdateMatrixRoom(ctx, user, group, nil)
- }
- }
- return nil
-}
-
-const WATypingTimeout = 15 * time.Second
-
-func (user *User) handleChatPresence(ctx context.Context, presence *events.ChatPresence) {
- puppet := user.bridge.GetPuppetByJID(presence.Sender)
- if puppet == nil {
- return
- }
- portal := user.GetPortalByJID(presence.Chat)
- if puppet == nil || portal == nil || len(portal.MXID) == 0 {
- return
- }
- if presence.State == types.ChatPresenceComposing {
- if puppet.typingIn != "" && puppet.typingAt.Add(WATypingTimeout).Before(time.Now()) {
- if puppet.typingIn == portal.MXID {
- return
- }
- _, _ = puppet.IntentFor(portal).UserTyping(ctx, puppet.typingIn, false, 0)
- }
- _, _ = puppet.IntentFor(portal).UserTyping(ctx, portal.MXID, true, WATypingTimeout)
- puppet.typingIn = portal.MXID
- puppet.typingAt = time.Now()
- } else {
- _, _ = puppet.IntentFor(portal).UserTyping(ctx, portal.MXID, false, 0)
- puppet.typingIn = ""
- }
-}
-
-func (user *User) handleReceipt(receipt *events.Receipt) {
- if receipt.Type != types.ReceiptTypeRead && receipt.Type != types.ReceiptTypeReadSelf && receipt.Type != types.ReceiptTypeDelivered {
- return
- }
- portal := user.GetPortalByMessageSource(receipt.MessageSource)
- if portal == nil || len(portal.MXID) == 0 {
- return
- }
- portal.events <- &PortalEvent{
- Message: &PortalMessage{receipt: receipt, source: user},
- }
-}
-
-func (user *User) makeReadMarkerContent(eventID id.EventID, doublePuppet bool) CustomReadMarkers {
- var extra CustomReadReceipt
- if doublePuppet {
- extra.DoublePuppetSource = user.bridge.Name
- }
- return CustomReadMarkers{
- ReqSetReadMarkers: mautrix.ReqSetReadMarkers{
- Read: eventID,
- FullyRead: eventID,
- },
- ReadExtra: extra,
- FullyReadExtra: extra,
- }
-}
-
-func (user *User) markSelfReadFull(ctx context.Context, portal *Portal) {
- puppet := user.bridge.GetPuppetByCustomMXID(user.MXID)
- if puppet == nil || puppet.CustomIntent() == nil {
- return
- }
- lastMessage, err := user.bridge.DB.Message.GetLastInChat(ctx, portal.Key)
- if err != nil {
- user.zlog.Err(err).Msg("Failed to get last message in chat to mark as read")
- return
- } else if lastMessage == nil {
- return
- }
- user.SetLastReadTS(ctx, portal.Key, lastMessage.Timestamp)
- err = puppet.CustomIntent().SetReadMarkers(ctx, portal.MXID, user.makeReadMarkerContent(lastMessage.MXID, true))
- if err != nil {
- user.zlog.Err(err).
- Stringer("portal_mxid", portal.MXID).
- Stringer("last_message_mxid", lastMessage.MXID).
- Msg("Failed to mark last message in chat as read")
- } else {
- user.zlog.Debug().
- Stringer("portal_mxid", portal.MXID).
- Stringer("last_message_mxid", lastMessage.MXID).
- Msg("Marked last message in chat as read")
- }
-}
-
-func (user *User) markUnread(ctx context.Context, portal *Portal, unread bool) {
- puppet := user.bridge.GetPuppetByCustomMXID(user.MXID)
- if puppet == nil || puppet.CustomIntent() == nil {
- return
- }
-
- err := puppet.CustomIntent().SetRoomAccountData(ctx, portal.MXID, "m.marked_unread",
- map[string]bool{"unread": unread})
- if err != nil {
- user.zlog.Err(err).Stringer("portal_mxid", portal.MXID).Msg("Failed to mark room as unread (m.marked_unread)")
- } else {
- user.zlog.Debug().Stringer("portal_mxid", portal.MXID).Msg("Marked room as unread (m.marked_unread)")
- }
-
- err = puppet.CustomIntent().SetRoomAccountData(ctx, portal.MXID, "com.famedly.marked_unread",
- map[string]bool{"unread": unread})
- if err != nil {
- user.zlog.Err(err).Stringer("portal_mxid", portal.MXID).Msg("Failed to mark room as unread (com.famedly.marked_unread)")
- } else {
- user.zlog.Debug().Stringer("portal_mxid", portal.MXID).Msg("Marked room as unread (com.famedly.marked_unread)")
- }
-}
-
-func (user *User) handleGroupCreate(evt *events.JoinedGroup) {
- log := user.zlog.With().Str("whatsapp_event", "JoinedGroup").Logger()
- ctx := log.WithContext(context.TODO())
- portal := user.GetPortalByJID(evt.JID)
- if evt.CreateKey == "" && len(portal.MXID) == 0 && portal.Key.JID != user.skipGroupCreateDelay {
- log.Debug().Msg("Delaying handling group create with empty key to avoid race conditions")
- time.Sleep(5 * time.Second)
- }
- if len(portal.MXID) == 0 {
- if user.createKeyDedup != "" && evt.CreateKey == user.createKeyDedup {
- log.Debug().Str("create_key", evt.CreateKey).Msg("Ignoring group create event with cached create key")
- return
- }
- err := portal.CreateMatrixRoom(ctx, user, &evt.GroupInfo, nil, true, true)
- if err != nil {
- log.Err(err).Msg("Failed to create Matrix room after join notification")
- }
- } else {
- portal.UpdateMatrixRoom(ctx, user, &evt.GroupInfo, nil)
- }
-}
-
-func (user *User) handleGroupUpdate(evt *events.GroupInfo) {
- portal := user.GetPortalByJID(evt.JID)
- with := user.zlog.With().
- Str("chat_jid", evt.JID.String()).
- Interface("group_event", evt)
- if portal != nil {
- with = with.Str("portal_mxid", portal.MXID.String())
- }
- log := with.Logger()
- if portal == nil || len(portal.MXID) == 0 {
- log.Debug().Msg("Ignoring group info update in chat with no portal")
- return
- }
- if evt.Sender != nil && evt.Sender.Server == types.HiddenUserServer {
- log.Debug().Str("sender", evt.Sender.String()).Msg("Ignoring group info update from @lid user")
- return
- }
- ctx := log.WithContext(context.TODO())
- switch {
- case evt.Announce != nil:
- log.Debug().Msg("Group announcement mode (message send permission) changed")
- portal.RestrictMessageSending(ctx, evt.Announce.IsAnnounce)
- case evt.Locked != nil:
- log.Debug().Msg("Group locked mode (metadata change permission) changed")
- portal.RestrictMetadataChanges(ctx, evt.Locked.IsLocked)
- case evt.Name != nil:
- log.Debug().Msg("Group name changed")
- portal.UpdateName(ctx, evt.Name.Name, evt.Name.NameSetBy, true)
- case evt.Topic != nil:
- log.Debug().Msg("Group topic changed")
- portal.UpdateTopic(ctx, evt.Topic.Topic, evt.Topic.TopicSetBy, true)
- case evt.Leave != nil:
- log.Debug().Msg("Someone left the group")
- if evt.Sender != nil && !evt.Sender.IsEmpty() {
- portal.HandleWhatsAppKick(ctx, user, *evt.Sender, evt.Leave)
- }
- case evt.Join != nil:
- log.Debug().Msg("Someone joined the group")
- portal.HandleWhatsAppInvite(ctx, user, evt.Sender, evt.Join)
- case evt.Promote != nil:
- log.Debug().Msg("Someone was promoted to admin")
- portal.ChangeAdminStatus(ctx, evt.Promote, true)
- case evt.Demote != nil:
- log.Debug().Msg("Someone was demoted from admin")
- portal.ChangeAdminStatus(ctx, evt.Demote, false)
- case evt.Ephemeral != nil:
- log.Debug().Msg("Group ephemeral mode (disappearing message timer) changed")
- portal.UpdateGroupDisappearingMessages(ctx, evt.Sender, evt.Timestamp, evt.Ephemeral.DisappearingTimer)
- case evt.Link != nil:
- log.Debug().Msg("Group parent changed")
- if evt.Link.Type == types.GroupLinkChangeTypeParent {
- portal.UpdateParentGroup(ctx, user, evt.Link.Group.JID, true)
- }
- case evt.Unlink != nil:
- log.Debug().Msg("Group parent removed")
- if evt.Unlink.Type == types.GroupLinkChangeTypeParent && portal.ParentGroup == evt.Unlink.Group.JID {
- portal.UpdateParentGroup(ctx, user, types.EmptyJID, true)
- }
- case evt.Delete != nil:
- log.Debug().Msg("Group deleted")
- portal.Delete(ctx)
- portal.Cleanup(ctx, false)
- default:
- log.Warn().Msg("Unhandled group info update")
- }
-}
-
-func (user *User) handleNewsletterJoin(evt *events.NewsletterJoin) {
- ctx := user.zlog.With().Str("whatsapp_event", "NewsletterJoin").Logger().WithContext(context.TODO())
- portal := user.GetPortalByJID(evt.ID)
- if portal.MXID == "" {
- err := portal.CreateMatrixRoom(ctx, user, nil, &evt.NewsletterMetadata, true, false)
- if err != nil {
- user.zlog.Err(err).Msg("Failed to create room on newsletter join event")
- }
- } else {
- portal.UpdateMatrixRoom(ctx, user, nil, &evt.NewsletterMetadata)
- }
-}
-
-func (user *User) handleNewsletterLeave(evt *events.NewsletterLeave) {
- ctx := user.zlog.With().Str("whatsapp_event", "NewsletterLeave").Logger().WithContext(context.TODO())
- portal := user.GetPortalByJID(evt.ID)
- if portal.MXID != "" {
- portal.HandleWhatsAppKick(ctx, user, user.JID, []types.JID{user.JID})
- }
-}
-
-func (user *User) handlePictureUpdate(ctx context.Context, evt *events.Picture) {
- if evt.JID.Server == types.DefaultUserServer {
- puppet := user.bridge.GetPuppetByJID(evt.JID)
- user.zlog.Debug().
- Stringer("jid", evt.JID).
- Str("current_avatar", puppet.Avatar).
- Str("new_avatar", evt.PictureID).
- Msg("Received picture update for puppet")
- if puppet.Avatar != evt.PictureID {
- puppet.Sync(ctx, user, nil, true, false)
- }
- } else if portal := user.GetPortalByJID(evt.JID); portal != nil {
- user.zlog.Debug().
- Stringer("jid", evt.JID).
- Str("current_avatar", portal.Avatar).
- Str("new_avatar", evt.PictureID).
- Msg("Received picture update for portal")
- if portal.Avatar != evt.PictureID {
- portal.UpdateAvatar(ctx, user, evt.Author, true)
- }
- }
-}
-
-func (user *User) StartPM(ctx context.Context, jid types.JID, reason string) (*Portal, *Puppet, bool, error) {
- zerolog.Ctx(ctx).Debug().Stringer("jid", jid).Str("source", reason).Msg("Starting PM with user")
- puppet := user.bridge.GetPuppetByJID(jid)
- puppet.SyncContact(ctx, user, true, false, reason)
- portal := user.GetPortalByJID(puppet.JID)
- if len(portal.MXID) > 0 {
- ok := portal.ensureUserInvited(ctx, user)
- if !ok {
- zerolog.Ctx(ctx).Warn().Msg("Failed to ensure user is invited to room in StartPM, creating new portal")
- portal.MXID = ""
- portal.updateLogger()
- } else {
- return portal, puppet, false, nil
- }
- }
- err := portal.CreateMatrixRoom(ctx, user, nil, nil, false, true)
- return portal, puppet, true, err
-}
-
-const groupListCacheMaxAge = 24 * time.Hour
-
-func (user *User) getCachedGroupList() ([]*types.GroupInfo, error) {
- user.groupListCacheLock.Lock()
- defer user.groupListCacheLock.Unlock()
- if user.groupListCache != nil && user.groupListCacheTime.Add(groupListCacheMaxAge).After(time.Now()) {
- return user.groupListCache, nil
- }
- var err error
- user.groupListCache, err = user.Client.GetJoinedGroups()
- user.groupListCacheTime = time.Now()
- return user.groupListCache, err
-}