// Copyright (c) 2021 Tulir Asokan // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. package main import ( "bufio" "context" "encoding/hex" "encoding/json" "errors" "flag" "fmt" "mime" "net/http" "os" "os/signal" "strconv" "strings" "sync/atomic" "syscall" "time" _ "github.com/mattn/go-sqlite3" "github.com/mdp/qrterminal/v3" "google.golang.org/protobuf/proto" "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/appstate" waBinary "go.mau.fi/whatsmeow/binary" 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/whatsmeow/types/events" waLog "go.mau.fi/whatsmeow/util/log" ) var cli *whatsmeow.Client var log waLog.Logger var logLevel = "INFO" var debugLogs = flag.Bool("debug", false, "Enable debug logs?") var dbDialect = flag.String("db-dialect", "sqlite3", "Database dialect (sqlite3 or postgres)") var dbAddress = flag.String("db-address", "file:mdtest.db?_foreign_keys=on", "Database address") var requestFullSync = flag.Bool("request-full-sync", false, "Request full (1 year) history sync when logging in?") var pairRejectChan = make(chan bool, 1) func main() { waBinary.IndentXML = true flag.Parse() if *debugLogs { logLevel = "DEBUG" } if *requestFullSync { store.DeviceProps.RequireFullSync = proto.Bool(true) store.DeviceProps.HistorySyncConfig = &waProto.DeviceProps_HistorySyncConfig{ FullSyncDaysLimit: proto.Uint32(3650), FullSyncSizeMbLimit: proto.Uint32(102400), StorageQuotaMb: proto.Uint32(102400), } } log = waLog.Stdout("Main", logLevel, true) dbLog := waLog.Stdout("Database", logLevel, true) storeContainer, err := sqlstore.New(*dbDialect, *dbAddress, dbLog) if err != nil { log.Errorf("Failed to connect to database: %v", err) return } device, err := storeContainer.GetFirstDevice() if err != nil { log.Errorf("Failed to get device: %v", err) return } cli = whatsmeow.NewClient(device, waLog.Stdout("Client", logLevel, true)) var isWaitingForPair atomic.Bool cli.PrePairCallback = func(jid types.JID, platform, businessName string) bool { isWaitingForPair.Store(true) defer isWaitingForPair.Store(false) log.Infof("Pairing %s (platform: %q, business name: %q). Type r within 3 seconds to reject pair", jid, platform, businessName) select { case reject := <-pairRejectChan: if reject { log.Infof("Rejecting pair") return false } case <-time.After(3 * time.Second): } log.Infof("Accepting pair") return true } ch, err := cli.GetQRChannel(context.Background()) if err != nil { // This error means that we're already logged in, so ignore it. if !errors.Is(err, whatsmeow.ErrQRStoreContainsID) { log.Errorf("Failed to get QR channel: %v", err) } } else { go func() { for evt := range ch { if evt.Event == "code" { qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout) } else { log.Infof("QR channel result: %s", evt.Event) } } }() } cli.AddEventHandler(handler) err = cli.Connect() if err != nil { log.Errorf("Failed to connect: %v", err) return } c := make(chan os.Signal, 1) input := make(chan string) signal.Notify(c, os.Interrupt, syscall.SIGTERM) go func() { defer close(input) scan := bufio.NewScanner(os.Stdin) for scan.Scan() { line := strings.TrimSpace(scan.Text()) if len(line) > 0 { input <- line } } }() for { select { case <-c: log.Infof("Interrupt received, exiting") cli.Disconnect() return case cmd := <-input: if len(cmd) == 0 { log.Infof("Stdin closed, exiting") cli.Disconnect() return } if isWaitingForPair.Load() { if cmd == "r" { pairRejectChan <- true } else if cmd == "a" { pairRejectChan <- false } continue } args := strings.Fields(cmd) cmd = args[0] args = args[1:] go handleCmd(strings.ToLower(cmd), args) } } } func parseJID(arg string) (types.JID, bool) { if arg[0] == '+' { arg = arg[1:] } if !strings.ContainsRune(arg, '@') { return types.NewJID(arg, types.DefaultUserServer), true } else { recipient, err := types.ParseJID(arg) if err != nil { log.Errorf("Invalid JID %s: %v", arg, err) return recipient, false } else if recipient.User == "" { log.Errorf("Invalid JID %s: no server specified", arg) return recipient, false } return recipient, true } } func handleCmd(cmd string, args []string) { switch cmd { case "pair-phone": if len(args) < 1 { log.Errorf("Usage: pair-phone ") return } linkingCode, err := cli.PairPhone(args[0], true, whatsmeow.PairClientChrome, "Chrome (Linux)") if err != nil { panic(err) } fmt.Println("Linking code:", linkingCode) case "reconnect": cli.Disconnect() err := cli.Connect() if err != nil { log.Errorf("Failed to connect: %v", err) } case "logout": err := cli.Logout() if err != nil { log.Errorf("Error logging out: %v", err) } else { log.Infof("Successfully logged out") } case "appstate": if len(args) < 1 { log.Errorf("Usage: appstate ") return } names := []appstate.WAPatchName{appstate.WAPatchName(args[0])} if args[0] == "all" { names = []appstate.WAPatchName{appstate.WAPatchRegular, appstate.WAPatchRegularHigh, appstate.WAPatchRegularLow, appstate.WAPatchCriticalUnblockLow, appstate.WAPatchCriticalBlock} } resync := len(args) > 1 && args[1] == "resync" for _, name := range names { err := cli.FetchAppState(name, resync, false) if err != nil { log.Errorf("Failed to sync app state: %v", err) } } case "request-appstate-key": if len(args) < 1 { log.Errorf("Usage: request-appstate-key ") return } var keyIDs = make([][]byte, len(args)) for i, id := range args { decoded, err := hex.DecodeString(id) if err != nil { log.Errorf("Failed to decode %s as hex: %v", id, err) return } keyIDs[i] = decoded } cli.DangerousInternals().RequestAppStateKeys(context.Background(), keyIDs) case "unavailable-request": if len(args) < 3 { log.Errorf("Usage: unavailable-request ") return } chat, ok := parseJID(args[0]) if !ok { return } sender, ok := parseJID(args[1]) if !ok { return } resp, err := cli.SendMessage( context.Background(), cli.Store.ID.ToNonAD(), cli.BuildUnavailableMessageRequest(chat, sender, args[2]), whatsmeow.SendRequestExtra{Peer: true}, ) fmt.Println(resp) fmt.Println(err) case "checkuser": if len(args) < 1 { log.Errorf("Usage: checkuser ") return } resp, err := cli.IsOnWhatsApp(args) if err != nil { log.Errorf("Failed to check if users are on WhatsApp:", err) } else { for _, item := range resp { if item.VerifiedName != nil { log.Infof("%s: on whatsapp: %t, JID: %s, business name: %s", item.Query, item.IsIn, item.JID, item.VerifiedName.Details.GetVerifiedName()) } else { log.Infof("%s: on whatsapp: %t, JID: %s", item.Query, item.IsIn, item.JID) } } } case "checkupdate": resp, err := cli.CheckUpdate() if err != nil { log.Errorf("Failed to check for updates: %v", err) } else { log.Debugf("Version data: %#v", resp) if resp.ParsedVersion == store.GetWAVersion() { log.Infof("Client is up to date") } else if store.GetWAVersion().LessThan(resp.ParsedVersion) { log.Warnf("Client is outdated") } else { log.Infof("Client is newer than latest") } } case "subscribepresence": if len(args) < 1 { log.Errorf("Usage: subscribepresence ") return } jid, ok := parseJID(args[0]) if !ok { return } err := cli.SubscribePresence(jid) if err != nil { fmt.Println(err) } case "presence": if len(args) == 0 { log.Errorf("Usage: presence ") return } fmt.Println(cli.SendPresence(types.Presence(args[0]))) case "chatpresence": if len(args) == 2 { args = append(args, "") } else if len(args) < 2 { log.Errorf("Usage: chatpresence [audio]") return } jid, _ := types.ParseJID(args[0]) fmt.Println(cli.SendChatPresence(jid, types.ChatPresence(args[1]), types.ChatPresenceMedia(args[2]))) case "privacysettings": resp, err := cli.TryFetchPrivacySettings(false) if err != nil { fmt.Println(err) } else { fmt.Printf("%+v\n", resp) } case "setprivacysetting": if len(args) < 2 { log.Errorf("Usage: setprivacysetting ") return } setting := types.PrivacySettingType(args[0]) value := types.PrivacySetting(args[1]) resp, err := cli.SetPrivacySetting(setting, value) if err != nil { fmt.Println(err) } else { fmt.Printf("%+v\n", resp) } case "getuser": if len(args) < 1 { log.Errorf("Usage: getuser ") return } var jids []types.JID for _, arg := range args { jid, ok := parseJID(arg) if !ok { return } jids = append(jids, jid) } resp, err := cli.GetUserInfo(jids) if err != nil { log.Errorf("Failed to get user info: %v", err) } else { for jid, info := range resp { log.Infof("%s: %+v", jid, info) } } case "mediaconn": conn, err := cli.DangerousInternals().RefreshMediaConn(false) if err != nil { log.Errorf("Failed to get media connection: %v", err) } else { log.Infof("Media connection: %+v", conn) } case "raw": var node waBinary.Node if err := json.Unmarshal([]byte(strings.Join(args, " ")), &node); err != nil { log.Errorf("Failed to parse args as JSON into XML node: %v", err) } else if err = cli.DangerousInternals().SendNode(node); err != nil { log.Errorf("Error sending node: %v", err) } else { log.Infof("Node sent") } case "listnewsletters": newsletters, err := cli.GetSubscribedNewsletters() if err != nil { log.Errorf("Failed to get subscribed newsletters: %v", err) return } for _, newsletter := range newsletters { log.Infof("* %s: %s", newsletter.ID, newsletter.ThreadMeta.Name.Text) } case "getnewsletter": jid, ok := parseJID(args[0]) if !ok { return } meta, err := cli.GetNewsletterInfo(jid) if err != nil { log.Errorf("Failed to get info: %v", err) } else { log.Infof("Got info: %+v", meta) } case "getnewsletterinvite": meta, err := cli.GetNewsletterInfoWithInvite(args[0]) if err != nil { log.Errorf("Failed to get info: %v", err) } else { log.Infof("Got info: %+v", meta) } case "livesubscribenewsletter": if len(args) < 1 { log.Errorf("Usage: livesubscribenewsletter ") return } jid, ok := parseJID(args[0]) if !ok { return } dur, err := cli.NewsletterSubscribeLiveUpdates(context.TODO(), jid) if err != nil { log.Errorf("Failed to subscribe to live updates: %v", err) } else { log.Infof("Subscribed to live updates for %s for %s", jid, dur) } case "getnewslettermessages": if len(args) < 1 { log.Errorf("Usage: getnewslettermessages [count] [before id]") return } jid, ok := parseJID(args[0]) if !ok { return } count := 100 var err error if len(args) > 1 { count, err = strconv.Atoi(args[1]) if err != nil { log.Errorf("Invalid count: %v", err) return } } var before types.MessageServerID if len(args) > 2 { before, err = strconv.Atoi(args[2]) if err != nil { log.Errorf("Invalid message ID: %v", err) return } } messages, err := cli.GetNewsletterMessages(jid, &whatsmeow.GetNewsletterMessagesParams{Count: count, Before: before}) if err != nil { log.Errorf("Failed to get messages: %v", err) } else { for _, msg := range messages { log.Infof("%d: %+v (viewed %d times)", msg.MessageServerID, msg.Message, msg.ViewsCount) } } case "createnewsletter": if len(args) < 1 { log.Errorf("Usage: createnewsletter ") return } resp, err := cli.CreateNewsletter(whatsmeow.CreateNewsletterParams{ Name: strings.Join(args, " "), }) if err != nil { log.Errorf("Failed to create newsletter: %v", err) } else { log.Infof("Created newsletter %+v", resp) } case "getavatar": if len(args) < 1 { log.Errorf("Usage: getavatar [existing ID] [--preview] [--community]") return } jid, ok := parseJID(args[0]) if !ok { return } existingID := "" if len(args) > 2 { existingID = args[2] } var preview, isCommunity bool for _, arg := range args { if arg == "--preview" { preview = true } else if arg == "--community" { isCommunity = true } } pic, err := cli.GetProfilePictureInfo(jid, &whatsmeow.GetProfilePictureParams{ Preview: preview, IsCommunity: isCommunity, ExistingID: existingID, }) if err != nil { log.Errorf("Failed to get avatar: %v", err) } else if pic != nil { log.Infof("Got avatar ID %s: %s", pic.ID, pic.URL) } else { log.Infof("No avatar found") } case "getgroup": if len(args) < 1 { log.Errorf("Usage: getgroup ") return } group, ok := parseJID(args[0]) if !ok { return } else if group.Server != types.GroupServer { log.Errorf("Input must be a group JID (@%s)", types.GroupServer) return } resp, err := cli.GetGroupInfo(group) if err != nil { log.Errorf("Failed to get group info: %v", err) } else { log.Infof("Group info: %+v", resp) } case "subgroups": if len(args) < 1 { log.Errorf("Usage: subgroups ") return } group, ok := parseJID(args[0]) if !ok { return } else if group.Server != types.GroupServer { log.Errorf("Input must be a group JID (@%s)", types.GroupServer) return } resp, err := cli.GetSubGroups(group) if err != nil { log.Errorf("Failed to get subgroups: %v", err) } else { for _, sub := range resp { log.Infof("Subgroup: %+v", sub) } } case "communityparticipants": if len(args) < 1 { log.Errorf("Usage: communityparticipants ") return } group, ok := parseJID(args[0]) if !ok { return } else if group.Server != types.GroupServer { log.Errorf("Input must be a group JID (@%s)", types.GroupServer) return } resp, err := cli.GetLinkedGroupsParticipants(group) if err != nil { log.Errorf("Failed to get community participants: %v", err) } else { log.Infof("Community participants: %+v", resp) } case "listgroups": groups, err := cli.GetJoinedGroups() if err != nil { log.Errorf("Failed to get group list: %v", err) } else { for _, group := range groups { log.Infof("%+v", group) } } case "getinvitelink": if len(args) < 1 { log.Errorf("Usage: getinvitelink [--reset]") return } group, ok := parseJID(args[0]) if !ok { return } else if group.Server != types.GroupServer { log.Errorf("Input must be a group JID (@%s)", types.GroupServer) return } resp, err := cli.GetGroupInviteLink(group, len(args) > 1 && args[1] == "--reset") if err != nil { log.Errorf("Failed to get group invite link: %v", err) } else { log.Infof("Group invite link: %s", resp) } case "queryinvitelink": if len(args) < 1 { log.Errorf("Usage: queryinvitelink ") return } resp, err := cli.GetGroupInfoFromLink(args[0]) if err != nil { log.Errorf("Failed to resolve group invite link: %v", err) } else { log.Infof("Group info: %+v", resp) } case "querybusinesslink": if len(args) < 1 { log.Errorf("Usage: querybusinesslink ") return } resp, err := cli.ResolveBusinessMessageLink(args[0]) if err != nil { log.Errorf("Failed to resolve business message link: %v", err) } else { log.Infof("Business info: %+v", resp) } case "joininvitelink": if len(args) < 1 { log.Errorf("Usage: acceptinvitelink ") return } groupID, err := cli.JoinGroupWithLink(args[0]) if err != nil { log.Errorf("Failed to join group via invite link: %v", err) } else { log.Infof("Joined %s", groupID) } case "updateparticipant": if len(args) < 3 { log.Errorf("Usage: updateparticipant ") return } jid, ok := parseJID(args[0]) if !ok { return } action := whatsmeow.ParticipantChange(args[1]) switch action { case whatsmeow.ParticipantChangeAdd, whatsmeow.ParticipantChangeRemove, whatsmeow.ParticipantChangePromote, whatsmeow.ParticipantChangeDemote: default: log.Errorf("Valid actions: add, remove, promote, demote") return } users := make([]types.JID, len(args)-2) for i, arg := range args[2:] { users[i], ok = parseJID(arg) if !ok { return } } resp, err := cli.UpdateGroupParticipants(jid, users, action) if err != nil { log.Errorf("Failed to add participant: %v", err) return } for _, item := range resp { if action == whatsmeow.ParticipantChangeAdd && item.Error == 403 && item.AddRequest != nil { log.Infof("Participant is private: %d %s %s %v", item.Error, item.JID, item.AddRequest.Code, item.AddRequest.Expiration) cli.SendMessage(context.TODO(), item.JID, &waProto.Message{ GroupInviteMessage: &waProto.GroupInviteMessage{ InviteCode: proto.String(item.AddRequest.Code), InviteExpiration: proto.Int64(item.AddRequest.Expiration.Unix()), GroupJid: proto.String(jid.String()), GroupName: proto.String("Test group"), Caption: proto.String("This is a test group"), }, }) } else if item.Error == 409 { log.Infof("Participant already in group: %d %s %+v", item.Error, item.JID) } else if item.Error == 0 { log.Infof("Added participant: %d %s %+v", item.Error, item.JID) } else { log.Infof("Unknown status: %d %s %+v", item.Error, item.JID) } } case "getrequestparticipant": if len(args) < 1 { log.Errorf("Usage: getrequestparticipant ") return } group, ok := parseJID(args[0]) if !ok { log.Errorf("Invalid JID") return } resp, err := cli.GetGroupRequestParticipants(group) if err != nil { log.Errorf("Failed to get request participants: %v", err) } else { log.Infof("Request participants: %+v", resp) } case "getstatusprivacy": resp, err := cli.GetStatusPrivacy() fmt.Println(err) fmt.Println(resp) case "setdisappeartimer": if len(args) < 2 { log.Errorf("Usage: setdisappeartimer ") return } days, err := strconv.Atoi(args[1]) if err != nil { log.Errorf("Invalid duration: %v", err) return } recipient, ok := parseJID(args[0]) if !ok { return } err = cli.SetDisappearingTimer(recipient, time.Duration(days)*24*time.Hour) if err != nil { log.Errorf("Failed to set disappearing timer: %v", err) } case "setdefaultdisappeartimer": if len(args) < 1 { log.Errorf("Usage: setdefaultdisappeartimer ") return } days, err := strconv.Atoi(args[0]) if err != nil { log.Errorf("Invalid duration: %v", err) return } err = cli.SetDefaultDisappearingTimer(time.Duration(days) * 24 * time.Hour) if err != nil { log.Errorf("Failed to set default disappearing timer: %v", err) } case "send": if len(args) < 2 { log.Errorf("Usage: send ") return } recipient, ok := parseJID(args[0]) if !ok { return } msg := &waProto.Message{Conversation: proto.String(strings.Join(args[1:], " "))} resp, err := cli.SendMessage(context.Background(), recipient, msg) if err != nil { log.Errorf("Error sending message: %v", err) } else { log.Infof("Message sent (server timestamp: %s)", resp.Timestamp) } case "sendpoll": if len(args) < 7 { log.Errorf("Usage: sendpoll --