import (
"cmp"
"context"
+ "crypto/sha256"
+ "encoding/hex"
"errors"
"fmt"
"log"
"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"
// 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."
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
}
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
}
// 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
logouts chan struct{}
wake chan struct{}
failures chan error
- batches *WhatsAppBatches
+ state *WhatsAppState
mutex sync.Mutex
container *sqlstore.Container
}
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)
}
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 {
}
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)
}
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
}
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:
return 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
+ }
+
+ 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.groupName(ctx, 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.
type WhatsAppMatch struct {
id string
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