// 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 connector import ( "context" "errors" "fmt" "html" "slices" "strings" "github.com/rs/zerolog" "go.mau.fi/util/exslices" "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/appstate" "go.mau.fi/whatsmeow/types" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/commands" "maunium.net/go/mautrix/bridgev2/simplevent" "go.mau.fi/mautrix-whatsapp/pkg/waid" ) var ( HelpSectionInvites = commands.HelpSection{Name: "Group invites", Order: 25} ) var cmdAccept = &commands.FullHandler{ Func: fnAccept, Name: "accept", Help: commands.HelpMeta{ Section: HelpSectionInvites, Description: "Accept a group invite. This can only be used in reply to a group invite message.", }, RequiresLogin: true, RequiresPortal: true, } func fnAccept(ce *commands.Event) { if len(ce.ReplyTo) == 0 { ce.Reply("You must reply to a group invite message when using this command.") } else if message, err := ce.Bridge.DB.Message.GetPartByMXID(ce.Ctx, ce.ReplyTo); err != nil { ce.Log.Err(err).Stringer("reply_to_mxid", ce.ReplyTo).Msg("Failed to get reply target event to handle !wa accept command") ce.Reply("Failed to get reply event") } else if message == nil { ce.Log.Warn().Stringer("reply_to_mxid", ce.ReplyTo).Msg("Reply target event not found to handle !wa accept command") ce.Reply("Reply event not found") } else if meta := message.Metadata.(*waid.MessageMetadata).GroupInvite; meta == nil { ce.Reply("That doesn't look like a group invite message.") } else if meta.Inviter.User == waid.ParseUserLoginID(ce.Portal.Receiver, 0).User { ce.Reply("You can't accept your own invites") } else if login := ce.Bridge.GetCachedUserLoginByID(ce.Portal.Receiver); login == nil { ce.Reply("Login not found") } else if !login.Client.IsLoggedIn() { ce.Reply("Not logged in") } else if err = login.Client.(*WhatsAppClient).Client.JoinGroupWithInvite(ce.Ctx, meta.JID, meta.Inviter, meta.Code, meta.Expiration); err != nil { ce.Log.Err(err).Msg("Failed to accept group invite") ce.Reply("Failed to accept group invite: %v", err) } else { ce.Reply("Successfully accepted the invite, the portal should be created momentarily") } } var cmdSync = &commands.FullHandler{ Func: fnSync, Name: "sync", Help: commands.HelpMeta{ Section: commands.HelpSectionAdmin, Description: "Sync data from WhatsApp.", Args: "", }, RequiresLogin: true, } func fnSync(ce *commands.Event) { login := ce.User.GetDefaultLogin() if login == nil { ce.Reply("Login not found") return } if len(ce.Args) == 0 { ce.Reply("Usage: `$cmdprefix sync `") return } logContext := func(c zerolog.Context) zerolog.Context { return c.Stringer("triggered_by_user", ce.User.MXID) } wa := login.Client.(*WhatsAppClient) switch strings.ToLower(ce.Args[0]) { case "group", "portal", "room": if ce.Portal == nil { ce.Reply("`!wa sync group` can only be used in a portal room.") return } login.QueueRemoteEvent(&simplevent.ChatResync{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventChatResync, PortalKey: ce.Portal.PortalKey, LogContext: logContext, }, GetChatInfoFunc: wa.GetChatInfo, }) ce.React("✅") case "groups": groups, err := wa.Client.GetJoinedGroups(ce.Ctx) if err != nil { ce.Reply("Failed to get joined groups: %v", err) return } for _, group := range groups { login.QueueRemoteEvent(&simplevent.ChatResync{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventChatResync, PortalKey: wa.makeWAPortalKey(group.JID), LogContext: logContext, CreatePortal: true, }, GetChatInfoFunc: func(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { wrapped := wa.wrapGroupInfo(ce.Ctx, group) wrapped.ExtraUpdates = bridgev2.MergeExtraUpdaters(wrapped.ExtraUpdates, updatePortalLastSyncAt) wa.addExtrasToWrapped(ce.Ctx, group.JID, wrapped, nil, portal.MXID == "") return wrapped, nil }, }) } ce.Reply("Queued syncs for %d groups", len(groups)) case "contacts": wa.resyncContacts(false, false) ce.React("✅") case "contacts-with-avatars": wa.resyncContacts(true, false) ce.React("✅") case "appstate": names := appstate.AllPatchNames[:] if len(ce.Args) > 1 { names = exslices.CastFuncFilter(ce.Args[1:], func(name string) (appstate.WAPatchName, bool) { if !slices.Contains(appstate.AllPatchNames[:], appstate.WAPatchName(name)) { ce.Reply("Invalid app state name `%s`", name) return "", false } return appstate.WAPatchName(name), true }) } for _, name := range names { err := wa.Client.FetchAppState(ce.Ctx, name, true, false) if errors.Is(err, appstate.ErrKeyNotFound) { ce.Reply("Key not found error syncing app state %s: %v\n\nKey requests are sent automatically, and the sync should happen in the background after your phone responds.", name, err) return } else if err != nil { ce.Reply("Error syncing app state %s: %v", name, err) } else if name == appstate.WAPatchCriticalUnblockLow { ce.Reply("Synced app state %s, contact sync running in background", name) } else { ce.Reply("Synced app state %s", name) } } default: ce.Reply("Unknown sync target `%s`", ce.Args[0]) } } var cmdInviteLink = &commands.FullHandler{ Func: fnInviteLink, Name: "invite-link", Help: commands.HelpMeta{ Section: HelpSectionInvites, Description: "Get an invite link to the current group chat, optionally regenerating the link and revoking the old link.", Args: "[--reset]", }, RequiresPortal: true, RequiresLogin: true, } func fnInviteLink(ce *commands.Event) { login := ce.User.GetDefaultLogin() if login == nil { ce.Reply("Login not found") return } portalJID, err := waid.ParsePortalID(ce.Portal.ID) if err != nil { ce.Reply("Failed to parse portal ID: %v", err) return } wa := login.Client.(*WhatsAppClient) reset := len(ce.Args) > 0 && strings.ToLower(ce.Args[0]) == "--reset" if portalJID.Server == types.DefaultUserServer || portalJID.Server == types.HiddenUserServer { ce.Reply("Can't get invite link to private chat") } else if portalJID.IsBroadcastList() { ce.Reply("Can't get invite link to broadcast list") } else if link, err := wa.Client.GetGroupInviteLink(ce.Ctx, portalJID, reset); err != nil { ce.Reply("Failed to get invite link: %v", err) } else { ce.Reply(link) } } var cmdResolveLink = &commands.FullHandler{ Func: fnResolveLink, Name: "resolve-link", Help: commands.HelpMeta{ Section: HelpSectionInvites, Description: "Resolve a WhatsApp group invite or business message link.", Args: "<_group, contact, or message link_>", }, RequiresLogin: true, } func fnResolveLink(ce *commands.Event) { if len(ce.Args) == 0 { ce.Reply("**Usage:** `$cmdprefix resolve-link `") return } login := ce.User.GetDefaultLogin() if login == nil { ce.Reply("Login not found") return } wa := login.Client.(*WhatsAppClient) if strings.HasPrefix(ce.Args[0], whatsmeow.InviteLinkPrefix) { group, err := wa.Client.GetGroupInfoFromLink(ce.Ctx, ce.Args[0]) if err != nil { ce.Reply("Failed to get group info: %v", err) return } ce.Reply("That invite link points at %s (`%s`)", group.Name, group.JID) } else if strings.HasPrefix(ce.Args[0], whatsmeow.BusinessMessageLinkPrefix) || strings.HasPrefix(ce.Args[0], whatsmeow.BusinessMessageLinkDirectPrefix) { target, err := wa.Client.ResolveBusinessMessageLink(ce.Ctx, ce.Args[0]) if err != nil { ce.Reply("Failed to get business info: %v", err) return } message := "" if len(target.Message) > 0 { parts := strings.Split(target.Message, "\n") for i, part := range parts { parts[i] = "> " + html.EscapeString(part) } message = fmt.Sprintf(" The following prefilled message is attached:\n\n%s", strings.Join(parts, "\n")) } ce.Reply("That link points at %s (+%s).%s", target.PushName, target.JID.User, message) } else if strings.HasPrefix(ce.Args[0], whatsmeow.ContactQRLinkPrefix) || strings.HasPrefix(ce.Args[0], whatsmeow.ContactQRLinkDirectPrefix) { target, err := wa.Client.ResolveContactQRLink(ce.Ctx, ce.Args[0]) if err != nil { ce.Reply("Failed to get contact info: %v", err) return } if target.PushName != "" { ce.Reply("That link points at %s (+%s)", target.PushName, target.JID.User) } else { ce.Reply("That link points at +%s", target.JID.User) } } else { ce.Reply("That doesn't look like a group invite link nor a business message link.") } } var cmdJoin = &commands.FullHandler{ Func: fnJoin, Name: "join", Help: commands.HelpMeta{ Section: HelpSectionInvites, Description: "Join a group chat with an invite link.", Args: "<_invite link_>", }, RequiresLogin: true, } func fnJoin(ce *commands.Event) { if len(ce.Args) == 0 { ce.Reply("**Usage:** `$cmdprefix join `") return } login := ce.User.GetDefaultLogin() if login == nil { ce.Reply("Login not found") return } wa := login.Client.(*WhatsAppClient) if strings.HasPrefix(ce.Args[0], whatsmeow.InviteLinkPrefix) { jid, err := wa.Client.JoinGroupWithLink(ce.Ctx, ce.Args[0]) if err != nil { ce.Reply("Failed to join group: %v", err) return } ce.Log.Debug().Stringer("group_jid", jid).Msg("User successfully joined WhatsApp group with link") ce.Reply("Successfully joined group `%s`, the portal should be created momentarily", jid) } else if strings.HasPrefix(ce.Args[0], whatsmeow.NewsletterLinkPrefix) { info, err := wa.Client.GetNewsletterInfoWithInvite(ce.Ctx, ce.Args[0]) if err != nil { ce.Reply("Failed to get channel info: %v", err) return } err = wa.Client.FollowNewsletter(ce.Ctx, info.ID) if err != nil { ce.Reply("Failed to follow channel: %v", err) return } ce.Log.Debug().Stringer("channel_jid", info.ID).Msg("User successfully followed WhatsApp channel with link") ce.Reply("Successfully followed channel `%s`, the portal should be created momentarily", info.ID) } else { ce.Reply("That doesn't look like a WhatsApp invite link") } }