X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/56d959f2d573eef1be5c04d0bf1659fa30d36e7c..75b9579355e1adcdbb1255a7e8e4582e0a431f3d:/whatsapp.go?ds=sidebyside diff --git a/whatsapp.go b/whatsapp.go index 20143a9..75de73f 100644 --- a/whatsapp.go +++ b/whatsapp.go @@ -14,12 +14,13 @@ import ( "time" "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/proto/waCompanionReg" + "go.mau.fi/whatsmeow/proto/waE2E" "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" - // The session store of the library opens SQLite with this pure Go // driver. _ "modernc.org/sqlite" @@ -68,19 +69,12 @@ const ( // wait for Run. whatsappRequests = 8 - // whatsappSource is the source of the work events of the batches. - whatsappSource = "whatsapp" - // whatsappLookupTime is the longest time of a read of the names in the // session database or of the groups from WhatsApp. whatsappLookupTime = 10 * time.Second - // whatsappGroupsRefresh is the shortest time between two reads of the - // groups from WhatsApp for a group with no name. - whatsappGroupsRefresh = time.Minute - - // whatsappUnknownGroup is the name of a group whose name is not known. - whatsappUnknownGroup = "A group with no known name" + // whatsappNotLinked is the answer of a WhatsApp tool without a link. + whatsappNotLinked = "WhatsApp is not linked. Tell the user to run /whatsapp pair PHONE in the terminal or in Telegram." ) // WhatsAppClient is the part of the WhatsApp client that the daemon uses. The @@ -93,6 +87,8 @@ type WhatsAppClient interface { PairPhone(ctx context.Context, phone string, showPushNotification bool, clientType whatsmeow.PairClientType, clientDisplayName string) (string, error) Logout(ctx context.Context) error GetJoinedGroups(ctx context.Context) ([]*types.GroupInfo, error) + GenerateMessageID() types.MessageID + SendMessage(ctx context.Context, to types.JID, message *waE2E.Message, extra ...whatsmeow.SendRequestExtra) (whatsmeow.SendResponse, error) } // openWhatsApp opens the WhatsApp session in the data directory dir. Without @@ -109,17 +105,17 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge noticeDelay: whatsappNoticeDelay, requests: make(chan WhatsAppRequest, whatsappRequests), logouts: make(chan struct{}, 1), - wake: make(chan struct{}, 1), - failures: make(chan error, 1), - groupNames: make(map[string]string), + groups: newWhatsAppGroups(logger), } var err error - w.batches, err = openWhatsAppBatches(filepath.Join(w.dir, whatsappStateFileName), whatsappBatchQuiet, whatsappBatchWindow) + w.state, err = openWhatsAppState(filepath.Join(w.dir, whatsappStateFileName), whatsappBatchQuiet, whatsappBatchWindow) if err != nil { return nil, err } + w.inbox = newWhatsAppInbox(w.state, clock, w.groups) + info, err := os.Stat(w.sessionPath()) if errors.Is(err, os.ErrNotExist) { return w, nil @@ -140,6 +136,7 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge if w.device.ID != nil { w.linked = true + w.inbox.waitForCatchup() w.own = whatsappOwnIDs(w.device.ID.User, w.device.LID.User) } @@ -159,13 +156,9 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge // client, and the state of the connection, which the event handler of the // library changes from its own goroutines. // -// The text messages go into batches, one chat at a time, as WhatsAppBatches -// says. Run logs each closed batch as a work message that starts with -// [WhatsApp], and a batch that needs the user wakes the turn loop. The event -// handler acknowledges a message only after the state file holds it, so the -// servers send again a message that a crash lost. A batch leaves the state file -// only after its work message is durable in the log. A crash between the two -// makes the batch repeat once. +// The inbox puts the text messages into batches and logs them, and groups +// holds the names of the groups. Both get a WhatsAppLink from link for each +// call, so they use the current device and client. // // The library logs nothing, because its log lines hold the ID of the own // account, which is the phone number of the user. The own number and the own @@ -182,9 +175,9 @@ type WhatsApp struct { noticeDelay time.Duration requests chan WhatsAppRequest logouts chan struct{} - wake chan struct{} - failures chan error - batches *WhatsAppBatches + state *WhatsAppState + groups *WhatsAppGroups + inbox *WhatsAppInbox mutex sync.Mutex container *sqlstore.Container @@ -200,8 +193,6 @@ type WhatsApp struct { noticed bool timer *time.Timer closed bool - groupNames map[string]string - groupsAt time.Time } // Run connects a linked device, logs the closed batches in l, runs the pair and @@ -230,24 +221,18 @@ func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) err }() for { - err := w.deliver(ctx, l) + err := w.inbox.deliver(ctx, l, w.link(), false) if err != nil { return err } - var due <-chan time.Time - wait, open := w.batches.Wait(time.Now()) - if open { - due = time.After(wait) - } - select { case <-ctx.Done(): return nil - case err = <-w.failures: + case err = <-w.inbox.failures: return err - case <-w.wake: - case <-due: + case <-w.inbox.wake: + case <-w.inbox.due(): case <-w.logouts: err = w.loggedOut(ctx, l) if err != nil { @@ -272,7 +257,7 @@ func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) err pairing.Go(func() { defer cancelPair() - w.pair(pairCtx, l, request.phone, request.answer) + w.pair(pairCtx, ctx, l, request.phone, request.answer) }) } } @@ -350,6 +335,17 @@ func (w *WhatsApp) Close() error { return err } +// Find returns the answer of whatsapp_find for name, as whatsappFind says. +func (w *WhatsApp) Find(ctx context.Context, name string) (string, error) { + return whatsappFind(ctx, w.link(), w.groups, name) +} + +// Send returns the answer of whatsapp_send for the send id of text to chat, as +// whatsappSend says. +func (w *WhatsApp) Send(ctx context.Context, id, chat, text string) (string, error) { + return whatsappSend(ctx, w.link(), w.groups, w.state, w.clock, id, chat, text) +} + func (w *WhatsApp) sessionPath() string { return filepath.Join(w.dir, whatsappSessionFileName) } @@ -385,6 +381,21 @@ func (w *WhatsApp) isLinked() bool { return w.linked } +// link returns a copy of the link of the device: whether it is linked, the +// device, the client, whether it is connected, and the own IDs. +func (w *WhatsApp) link() WhatsAppLink { + w.mutex.Lock() + defer w.mutex.Unlock() + + return WhatsAppLink{ + linked: w.linked, + device: w.device, + client: w.client, + connected: w.connected, + own: slices.Clone(w.own), + } +} + // connect makes the client of the linked device and connects it. func (w *WhatsApp) connect(ctx context.Context) { w.mutex.Lock() @@ -410,6 +421,8 @@ func (w *WhatsApp) close() { w.client = nil w.mutex.Unlock() + w.inbox.close() + if client != nil { client.Disconnect() } @@ -422,7 +435,7 @@ func (w *WhatsApp) loggedOut(ctx context.Context, l *TurnLoop) error { return nil } - err := w.deliverAll(ctx, l) + err := w.inbox.deliver(ctx, l, w.link(), true) if err != nil { return err } @@ -458,7 +471,7 @@ func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text str return nil } - err := w.deliverAll(ctx, l) + err := w.inbox.deliver(ctx, l, w.link(), true) if err != nil { return err } @@ -492,99 +505,6 @@ func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text str return nil } -// deliver logs the closed batches in l in order, each as a work message of -// whatsappSource that wakes the loop when the batch starts a turn. A batch -// leaves the state file after its message is durable in the log. A group with -// no name gets its name from WhatsApp first. -func (w *WhatsApp) deliver(ctx context.Context, l *TurnLoop) error { - _, err := w.batches.CloseDue(time.Now()) - if err != nil { - return err - } - - for _, batch := range w.batches.Closed() { - if batch.Chat.Group && batch.Chat.Name == "" { - batch.Chat.Name = w.groupName(ctx, batch.Chat.ID) - } - - _, err = l.Work(whatsappSource, batch.Text(w.clock().Location()), "", batch.Turn()) - if err != nil { - return fmt.Errorf("log a WhatsApp batch: %w", err) - } - - // A stop of the daemon during the flush is no failure: the - // batch stays in the state file and repeats once at the next - // start. - err = l.Flush(ctx) - if ctx.Err() != nil { - return nil - } - - if err != nil { - return fmt.Errorf("log a WhatsApp batch: %w", err) - } - - err = w.batches.Remove(batch.Number) - if err != nil { - return err - } - } - - return nil -} - -// deliverAll closes every open batch and logs all of them. -func (w *WhatsApp) deliverAll(ctx context.Context, l *TurnLoop) error { - err := w.batches.CloseAll() - if err != nil { - return err - } - - return w.deliver(ctx, l) -} - -// groupName returns the name of the group id, or whatsappUnknownGroup. A group -// that is not in the names of the groups reads the groups from WhatsApp again, -// at most once in whatsappGroupsRefresh. -func (w *WhatsApp) groupName(ctx context.Context, id string) string { - w.mutex.Lock() - name, found := w.groupNames[id] - client := w.client - fresh := time.Since(w.groupsAt) < whatsappGroupsRefresh - w.mutex.Unlock() - - if found { - return name - } - - if client == nil || fresh { - return whatsappUnknownGroup - } - - ctx, cancel := context.WithTimeout(ctx, whatsappLookupTime) - defer cancel() - - groups, err := client.GetJoinedGroups(ctx) - if err != nil { - w.logger.Printf("read the groups: %s", w.redact(err.Error())) - } - - w.mutex.Lock() - defer w.mutex.Unlock() - - w.groupsAt = time.Now() - for _, group := range groups { - w.groupNames[group.JID.String()] = group.Name - } - - name, found = w.groupNames[id] - if !found || name == "" { - return whatsappUnknownGroup - } - - return name -} - // removeSession ends the connection, closes the session database, and deletes // its files. func (w *WhatsApp) removeSession() error { @@ -604,6 +524,8 @@ func (w *WhatsApp) removeSession() error { w.device = nil w.mutex.Unlock() + w.inbox.stopCatchup() + if client != nil { client.Disconnect() } @@ -628,7 +550,13 @@ func (w *WhatsApp) removeSession() error { // number, an expired code, or a failure. A pairing that a new one ends gives no // answer. A link notes "WhatsApp is linked." in the log of l, with no number. // The client of a pairing that does not link is disconnected. -func (w *WhatsApp) pair(ctx context.Context, l *TurnLoop, phone string, answer func(text string)) { +// +// ctx limits the pairing, but the connection uses runCtx, the context of Run. +// The library keeps the context of the connection for its keep-alive, its event +// handler, and its reconnections, and after the link the server ends the stream +// and the library connects again. With ctx, the end of the pairing would stop +// that reconnection, and the linked device would never connect. +func (w *WhatsApp) pair(ctx, runCtx context.Context, l *TurnLoop, phone string, answer func(text string)) { device, err := w.newDevice(ctx) if err != nil { answer("The pairing failed: " + err.Error()) @@ -645,7 +573,7 @@ func (w *WhatsApp) pair(ctx context.Context, l *TurnLoop, phone string, answer f qr, err := client.GetQRChannel(ctx) if err == nil { - err = client.ConnectContext(ctx) + err = client.ConnectContext(runCtx) } if err != nil { @@ -667,6 +595,8 @@ func (w *WhatsApp) pair(ctx context.Context, l *TurnLoop, phone string, answer f return } + // The server accepts only a display name of the form "Browser (OS)" + // with a common browser and OS, and refuses the pairing otherwise. code, err := client.PairPhone(ctx, phone, true, whatsmeow.PairClientChrome, "Chrome (Linux)") if err != nil { answer("WhatsApp refused the pairing: " + err.Error() + ". Check the phone number and run /whatsapp pair PHONE again.") @@ -758,26 +688,35 @@ func (w *WhatsApp) linkedBy(client WhatsAppClient) { } // handle takes one event of the library and tells if it was handled. A message -// goes to receive. The names of the groups follow the events of the groups. A -// disconnection keeps the time of its start while the library tries again. -// After noticeDelay of a disconnection, all clients get one notice, and they -// get one more when the connection comes back. A disconnection that the library -// does not try again gives a notice at once. +// goes to the inbox. The names of the groups follow the events of the groups. A +// connection starts a catch-up of the inbox, which ends at OfflineSyncCompleted +// or after its catchupLimit. A disconnection keeps the time of its start while +// the library tries again. After noticeDelay of a disconnection, all clients +// get one notice, and they get one more when the connection comes back. A +// disconnection that the library does not try again gives a notice at once. func (w *WhatsApp) handle(event any) bool { - message, isMessage := event.(*events.Message) - if isMessage { - return w.receive(message) - } - - var notice string - w.mutex.Lock() switch event := event.(type) { + case *events.Message: + return w.inbox.receive(w.link(), event) case *events.GroupInfo: if event.Name != nil { - w.groupNames[event.JID.String()] = event.Name.Name + w.groups.set(event.JID.String(), event.Name.Name) } + + return true case *events.JoinedGroup: - w.groupNames[event.JID.String()] = event.Name + w.groups.set(event.JID.String(), event.Name) + return true + case *events.OfflineSyncCompleted: + w.inbox.endCatchup() + return true + case *events.Connected: + w.inbox.startCatchup() + } + + var notice string + w.mutex.Lock() + switch event := event.(type) { case *events.PairSuccess: w.own = whatsappOwnIDs(event.ID.User, event.LID.User) case *events.Connected: @@ -823,129 +762,6 @@ func (w *WhatsApp) handle(event any) bool { return true } -// receive puts the text of event into its batch and wakes Run. It returns false -// when the state file cannot hold the message, so that the library does not -// acknowledge it, and Run stops the daemon. A message that is not text, or that -// the batches do not take, is handled with nothing to do. -func (w *WhatsApp) receive(event *events.Message) bool { - ctx, cancel := context.WithTimeout(context.Background(), whatsappLookupTime) - defer cancel() - - chat, message, found := w.message(ctx, event) - if !found { - return true - } - - _, err := w.batches.Add(chat, message, time.Now()) - if err != nil { - select { - case w.failures <- err: - default: - } - - return false - } - - select { - case w.wake <- struct{}{}: - default: - } - - return true -} - -// message returns the chat and the batch message of event, or false when the -// batches do not take it. They take the text of a private chat or a group, from -// another person or from the user. They do not take media, reactions, edits, -// deletions, view-once and disappearing messages, old history, status -// broadcasts, channels, and the chat of the user with itself. -// -// A mention of the user becomes @you, another mention @NAME when the name is -// known, and the own number and LID become OWN, so that they never go to the -// log. -func (w *WhatsApp) message(ctx context.Context, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) { - w.mutex.Lock() - device := w.device - own := slices.Clone(w.own) - w.mutex.Unlock() - - info := event.Info - chatJID := info.Chat.ToNonAD() - switch { - case device == nil: - case event.IsEdit, info.Edit != "", event.IsEphemeral, event.IsViewOnce, event.SourceWebMsg != nil: - case chatJID.Server != types.DefaultUserServer && chatJID.Server != types.HiddenUserServer && chatJID.Server != types.GroupServer: - case slices.Contains(own, chatJID.User): - default: - return w.text(ctx, device, own, event) - } - - return WhatsAppChat{}, WhatsAppMessage{}, false -} - -// text returns the chat and the batch message of event, a message that the -// batches take when it has text. -func (w *WhatsApp) text(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) { - info := event.Info - text := event.Message.GetConversation() - extended := event.Message.GetExtendedTextMessage() - if text == "" { - text = extended.GetText() - } - - if strings.TrimSpace(text) == "" { - return WhatsAppChat{}, WhatsAppMessage{}, false - } - - contextInfo := extended.GetContextInfo() - message := WhatsAppMessage{ - ID: info.ID, - Time: info.Timestamp, - Mine: info.IsFromMe, - } - - if !message.Mine { - message.Sender = whatsappContactName(ctx, device, info.Sender, info.PushName) - } - - for _, mentioned := range contextInfo.GetMentionedJID() { - jid, err := types.ParseJID(mentioned) - switch { - case err != nil: - case slices.Contains(own, jid.User): - message.Mention = true - text = strings.ReplaceAll(text, "@"+jid.User, "@you") - default: - text = strings.ReplaceAll(text, "@"+jid.User, "@"+whatsappContactName(ctx, device, jid, "")) - } - } - - participant, err := types.ParseJID(contextInfo.GetParticipant()) - message.Reply = err == nil && slices.Contains(own, participant.User) - - for _, id := range own { - text = strings.ReplaceAll(text, id, "OWN") - } - - message.Text = text - chatJID := info.Chat.ToNonAD() - chat := WhatsAppChat{ - ID: chatJID.String(), - Group: chatJID.Server == types.GroupServer, - } - - if !chat.Group { - push := info.PushName - if message.Mine { - push = "" - } - - chat.Name = whatsappContactName(ctx, device, chatJID, push) - } - - return chat, message, true -} - // disconnectedLong gives the notice of a long disconnection, unless the // disconnection with the number disconnections ended. func (w *WhatsApp) disconnectedLong(disconnections int) { @@ -983,11 +799,17 @@ func (w *WhatsApp) redact(text string) string { w.mutex.Lock() defer w.mutex.Unlock() - for _, id := range w.own { - text = strings.ReplaceAll(text, id, "OWN") - } + return whatsappRedact(w.own, text) +} - return text +// WhatsAppLink is a copy of the link of the device at one time, for the parts +// of WhatsApp that do not own it: the groups, the batches, find, and send. +type WhatsAppLink struct { + linked bool + device *store.Device + client WhatsAppClient + connected bool + own []string } // WhatsAppRequest is a pair or unlink command for Run, with the function that @@ -998,12 +820,20 @@ type WhatsAppRequest struct { answer func(text string) } +// whatsappSetDeviceProps sets the device properties that the library sends at +// the pairing. They are global values of the library, so the daemon sets them +// once before any client exists. The phone shows the linked device with the +// name OneChat and a desktop icon only when the platform type is known. +func whatsappSetDeviceProps() { + store.SetOSInfo("OneChat", [3]uint32{0, 1, 0}) + store.DeviceProps.PlatformType = waCompanionReg.DeviceProps_DESKTOP.Enum() +} + // whatsappNewClient returns the client of the library for device, with handle // as its event handler. The client tries again after a failed first connection, // acknowledges a message only after handle returns true, and downloads no old -// history. The phone shows the device with the name OneChat. +// history. func whatsappNewClient(device *store.Device, handle func(event any) bool) WhatsAppClient { - store.SetOSInfo("OneChat", [3]uint32{0, 1, 0}) client := whatsmeow.NewClient(device, waLog.Noop) client.InitialAutoReconnect = true client.SynchronousAck = true @@ -1067,6 +897,15 @@ func whatsappOwnIDs(ids ...string) []string { return own } +// whatsappRedact replaces each of the own IDs own in text with OWN. +func whatsappRedact(own []string, text string) string { + for _, id := range own { + text = strings.ReplaceAll(text, id, "OWN") + } + + return text +} + // whatsappSessionAddress returns the SQLite address of the session database at // path, with foreign keys on, as the library needs, and a wait for a busy // database.