X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/38a9b0502a1ccefc6ccb3472c4a21805b222caa6..1804474c7a03cca8bf780dd870a985e7badb4331:/whatsapp.go diff --git a/whatsapp.go b/whatsapp.go index 0e71e98..929ae5d 100644 --- a/whatsapp.go +++ b/whatsapp.go @@ -3,10 +3,11 @@ package main import ( "cmp" "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "log" - "maps" "net/url" "os" "path/filepath" @@ -17,6 +18,7 @@ import ( "unicode" "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/store" "go.mau.fi/whatsmeow/store/sqlstore" "go.mau.fi/whatsmeow/types" @@ -81,27 +83,30 @@ const ( // 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" - // whatsappCatchupLimit is the longest wait for the end of the catch-up // after a connection. whatsappCatchupLimit = time.Minute - // whatsappGroupsAge is how long whatsapp_find uses the groups that it - // read from WhatsApp. - whatsappGroupsAge = time.Hour - // whatsappFindMatches is the most matches of whatsapp_find. whatsappFindMatches = 20 // 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." + // whatsappSendTime is the longest time of a send. + whatsappSendTime = 30 * time.Second + + // whatsappSendBytes is the most bytes of the text of a send. + whatsappSendBytes = 65536 + + // whatsappSendIDBytes is the most bytes of the ID of a send. + whatsappSendIDBytes = 100 + + // The states of a send record. + whatsappSending = "sending" + whatsappSent = "sent" + whatsappFailed = "failed" + // whatsappNamesNotReady is the answer of whatsapp_find before the names // arrive from the phone. whatsappNamesNotReady = "The names of the contacts and groups have not arrived from the phone yet. They come some minutes after the link. Try again later." @@ -117,6 +122,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 @@ -136,11 +143,11 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge 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 } @@ -185,9 +192,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 text messages go into batches, one chat at a time, as WhatsAppState 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. // // After each connection, the servers send the messages that came while the // daemon was away, and then OfflineSyncCompleted. Until then, or until @@ -218,7 +225,8 @@ type WhatsApp struct { logouts chan struct{} wake chan struct{} failures chan error - batches *WhatsAppBatches + state *WhatsAppState + groups *WhatsAppGroups mutex sync.Mutex container *sqlstore.Container @@ -234,9 +242,6 @@ type WhatsApp struct { noticed bool timer *time.Timer closed bool - groupNames map[string]string - groupsAt time.Time - groupsRead time.Time catchingUp bool caughtUp bool catchups int @@ -275,7 +280,7 @@ func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) err } var due <-chan time.Time - wait, open := w.batches.Wait(time.Now()) + wait, open := w.state.Wait(time.Now()) if open { due = time.After(wait) } @@ -311,7 +316,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) }) } } @@ -424,6 +429,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() @@ -551,11 +571,11 @@ func (w *WhatsApp) deliver(ctx context.Context, l *TurnLoop, all bool) error { var err error switch { case all || caughtUp: - err = w.batches.CloseAll() + err = w.state.CloseAll() case catchingUp: return nil default: - _, err = w.batches.CloseDue(time.Now()) + _, err = w.state.CloseDue(time.Now()) } if err != nil { @@ -563,9 +583,9 @@ func (w *WhatsApp) deliver(ctx context.Context, l *TurnLoop, all bool) error { } var turns []Message - for _, batch := range w.batches.Closed() { + for _, batch := range w.state.Closed() { if batch.Chat.Group && batch.Chat.Name == "" { - batch.Chat.Name = w.groupName(ctx, batch.Chat.ID) + batch.Chat.Name = w.groups.name(ctx, w.link(), batch.Chat.ID) } message, err := l.Work(whatsappSource, batch.Text(w.clock().Location()), "") @@ -585,7 +605,7 @@ func (w *WhatsApp) deliver(ctx context.Context, l *TurnLoop, all bool) error { return fmt.Errorf("log a WhatsApp batch: %w", err) } - err = w.batches.Remove(batch.Number) + err = w.state.Remove(batch.Number) if err != nil { return err } @@ -600,65 +620,6 @@ func (w *WhatsApp) deliver(ctx context.Context, l *TurnLoop, all bool) error { return nil } -// 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] - fresh := time.Since(w.groupsAt) < whatsappGroupsRefresh - w.mutex.Unlock() - - if !found && !fresh { - w.readGroups(ctx) - - w.mutex.Lock() - name, found = w.groupNames[id] - w.mutex.Unlock() - } - - if !found || name == "" { - return whatsappUnknownGroup - } - - return name -} - -// readGroups reads the names of the groups of the account from WhatsApp, and -// tells if it could. They stay in memory only. -func (w *WhatsApp) readGroups(ctx context.Context) bool { - w.mutex.Lock() - client := w.client - w.mutex.Unlock() - - if client == nil { - return false - } - - 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() - if err != nil { - return false - } - - w.groupsRead = w.groupsAt - for _, group := range groups { - w.groupNames[group.JID.String()] = group.Name - } - - return true -} - // Find returns the contacts and the groups whose name contains name, with no // regard to case and accents, as lines "ID | name | contact" or "ID | name | // group", sorted by name. It gives at most whatsappFindMatches lines and the @@ -671,9 +632,10 @@ func (w *WhatsApp) Find(ctx context.Context, name string) (string, error) { linked := w.linked device := w.device own := slices.Clone(w.own) - stale := time.Since(w.groupsRead) >= whatsappGroupsAge w.mutex.Unlock() + stale := w.groups.stale() + if !linked { return whatsappNotLinked, nil } @@ -688,10 +650,8 @@ func (w *WhatsApp) Find(ctx context.Context, name string) (string, error) { return "", fmt.Errorf("read the contacts: %s", w.redact(err.Error())) } - read := !stale || w.readGroups(ctx) - w.mutex.Lock() - groups := maps.Clone(w.groupNames) - w.mutex.Unlock() + read := !stale || w.groups.read(ctx, w.link()) + groups := w.groups.all() if len(contacts) == 0 && len(groups) == 0 { if !read { @@ -778,7 +738,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()) @@ -795,7 +761,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 { @@ -915,20 +881,23 @@ func (w *WhatsApp) linkedBy(client WhatsAppClient) { // 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.receive(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 + } + + 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: @@ -994,12 +963,17 @@ func (w *WhatsApp) receive(event *events.Message) bool { ctx, cancel := context.WithTimeout(context.Background(), whatsappLookupTime) defer cancel() - chat, message, found := w.message(ctx, event) + w.mutex.Lock() + device := w.device + own := slices.Clone(w.own) + w.mutex.Unlock() + + chat, message, found := whatsappMessage(ctx, device, own, event) if !found { return true } - _, err := w.batches.Add(chat, message, time.Now()) + _, err := w.state.Add(chat, message, time.Now()) if err != nil { select { case w.failures <- err: @@ -1017,98 +991,6 @@ func (w *WhatsApp) receive(event *events.Message) bool { 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 -} - // caughtUpBy ends the catch-up of the connection with the number catchups, when // no later connection started a new one. func (w *WhatsApp) caughtUpBy(catchups int) { @@ -1176,11 +1058,89 @@ 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) +} + +// Send sends text to chat, the ID of a private chat or a group, and returns the +// answer of the tool. id names the send: a call with an id that has a record +// gives the answer of the record and sends nothing, and the same id with +// another chat or text is an error. The record "sending" is durable before the +// send, so a stop of the daemon during the send gives an answer that the result +// is unknown, and not a second send. A disconnected WhatsApp sends nothing and +// makes no record. +func (w *WhatsApp) Send(ctx context.Context, id, chat, text string) (string, error) { + w.mutex.Lock() + linked := w.linked + device := w.device + client := w.client + connected := w.connected + own := slices.Clone(w.own) + w.mutex.Unlock() + + if !linked { + return whatsappNotLinked, nil } - return text + jid, err := whatsappSendCheck(id, chat, text, own) + if err != nil { + return "", err + } + + digest := sha256.Sum256([]byte(text)) + record := WhatsAppSend{ + ID: id, + Chat: chat, + Digest: hex.EncodeToString(digest[:]), + State: whatsappSending, + Time: w.clock(), + } + + held, found := w.state.Send(id) + switch { + case found && (held.Chat != record.Chat || held.Digest != record.Digest): + return "", fmt.Errorf("the id %s belongs to another message: give a new id for each message", id) + case found && held.State == whatsappSending: + return "The send with this id started, but its result is unknown, because the daemon stopped during it. Ask the user to look at the chat in WhatsApp before you send the message again with a new id.", nil + case found: + return held.Answer + " This is the answer of the first call with this id: nothing was sent again.", nil + case client == nil || !connected: + return "", errors.New("WhatsApp is disconnected, so nothing was sent: try again later") + } + + record.MessageID = client.GenerateMessageID() + err = w.state.PutSend(record) + if err != nil { + return "", err + } + + sendCtx, cancel := context.WithTimeout(ctx, whatsappSendTime) + defer cancel() + + response, err := client.SendMessage(sendCtx, jid, &waE2E.Message{ + Conversation: new(text), + }, whatsmeow.SendRequestExtra{ + ID: record.MessageID, + }) + + record.State = whatsappSent + if err != nil { + record.State = whatsappFailed + record.Answer = "The send failed: " + w.redact(err.Error()) + "." + } else { + name := w.groups.name(ctx, w.link(), jid.String()) + if jid.Server != types.GroupServer { + name = whatsappContactName(ctx, device, jid, "") + } + + record.Answer = "Sent to " + name + " at " + response.Timestamp.In(w.clock().Location()).Format("15:04") + "." + } + + err = w.state.PutSend(record) + if err != nil { + return "", err + } + + return record.Answer, nil } // WhatsAppMatch is one contact or group of Find. @@ -1190,6 +1150,16 @@ type WhatsAppMatch struct { kind string } +// 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 // gives its answers to the client that asked. A pairing has the phone number. type WhatsAppRequest struct { @@ -1213,6 +1183,90 @@ func whatsappNewClient(device *store.Device, handle func(event any) bool) WhatsA return client } +// whatsappMessage returns the chat and the batch message of event, or false +// when the batches do not take it. device is the linked device, and own holds +// the own IDs. The batches 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 whatsappMessage(ctx context.Context, device *store.Device, own []string, event *events.Message) (WhatsAppChat, WhatsAppMessage, bool) { + 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 whatsappText(ctx, device, own, event) + } + + return WhatsAppChat{}, WhatsAppMessage{}, false +} + +// whatsappText returns the chat and the batch message of event, a message that +// the batches take when it has text. +func whatsappText(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) + + message.Text = whatsappRedact(own, 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 +} + // whatsappContactName returns the name of the person jid: the full name, the // first name, the business name, or the push name of the contact in the session // of device, then push, the push name of a message, then the phone number. A @@ -1293,6 +1347,27 @@ func whatsappFindAnswer(name string, matches []WhatsAppMatch, groups bool) strin return strings.Join(lines, "\n") } +// whatsappSendCheck returns the JID of chat, and an error when id, chat, or +// text cannot go to a send. The chat is a private chat or a group, and not the +// chat of the user with itself. +func whatsappSendCheck(id, chat, text string, own []string) (types.JID, error) { + jid, err := types.ParseJID(chat) + switch { + case strings.TrimSpace(id) == "" || len(id) > whatsappSendIDBytes: + return types.EmptyJID, fmt.Errorf("the id must have 1 to %d bytes", whatsappSendIDBytes) + case strings.TrimSpace(text) == "" || len(text) > whatsappSendBytes: + return types.EmptyJID, fmt.Errorf("the text must have 1 to %d bytes", whatsappSendBytes) + case err != nil || jid.User == "" || jid != jid.ToNonAD() || jid.String() != chat: + return types.EmptyJID, fmt.Errorf("%q is not the ID of a WhatsApp chat: find the ID with whatsapp_find", chat) + case jid.Server != types.DefaultUserServer && jid.Server != types.HiddenUserServer && jid.Server != types.GroupServer: + return types.EmptyJID, fmt.Errorf("%q is not a private chat or a group", chat) + case slices.Contains(own, jid.User): + return types.EmptyJID, errors.New("this is the chat of the user with itself: send to another chat") + } + + return jid, nil +} + // whatsappOwnIDs returns the own number and the own LID that are not empty. func whatsappOwnIDs(ids ...string) []string { var own []string @@ -1305,6 +1380,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.