return w, nil
}
+func (w *WhatsApp) sessionPath() string {
+ return filepath.Join(w.dir, whatsappSessionFileName)
+}
+
+// openSession opens the session database and its first device. The caller holds
+// the mutex, or no other goroutine uses w yet.
+func (w *WhatsApp) openSession(ctx context.Context) error {
+ address, err := whatsappSessionAddress(w.sessionPath())
+ if err != nil {
+ return err
+ }
+
+ container, err := sqlstore.New(ctx, "sqlite", address, waLog.Noop)
+ if err != nil {
+ return fmt.Errorf("open WhatsApp session: %w", err)
+ }
+
+ device, err := container.GetFirstDevice(ctx)
+ if err != nil {
+ return errors.Join(fmt.Errorf("read WhatsApp device: %w", err), container.Close())
+ }
+
+ w.container = container
+ w.device = device
+
+ return nil
+}
+
// Run connects a linked device, logs the closed batches in l, runs the pair and
// unlink commands, and handles a logout from the phone, until ctx ends. Its
// notes go to the log of l, and notify gets the notices for all clients. A
cancel := func() {
}
+
defer func() {
cancel()
pairing.Wait()
}
}
-// Command runs the WhatsApp command args, the text after /whatsapp, and gives
-// each answer to answer. The answers go only to the client that sent the
-// command.
-func (w *WhatsApp) Command(args string, answer func(text string)) {
- fields := strings.Fields(args)
- request := WhatsAppRequest{
- answer: answer,
+// connect tries the starting connection of the linked device with one client.
+// It returns whether Run must retry. A failed call starts the shared outage
+// state and logs its redacted cause once, because a returned ConnectContext
+// error does not emit Disconnected. Connected logs recovery after
+// authentication.
+func (w *WhatsApp) connect(ctx context.Context) bool {
+ w.mutex.Lock()
+
+ if ctx.Err() != nil || !w.linked || !w.retrying || w.closed {
+ w.mutex.Unlock()
+
+ return false
}
- switch {
- case len(fields) == 1 && fields[0] == "status":
- answer(w.Status())
+ if w.client == nil {
+ w.client = w.newClient(w.device, w.handle)
+ }
- return
- case len(fields) == 1 && fields[0] == "unlink":
- request.unlink = true
- case len(fields) >= 2 && fields[0] == "pair":
- phone, valid := whatsappPhone(strings.Join(fields[1:], ""))
- if !valid {
- answer("PHONE must be the number of your WhatsApp account with its country code: 7 to 15 digits, such as +15550001111.")
+ client := w.client
+ w.mutex.Unlock()
- return
- }
+ err := client.ConnectContext(ctx)
- request.phone = phone
- default:
- answer(whatsappUsage)
+ w.mutex.Lock()
- return
+ if err == nil || ctx.Err() != nil || !w.retrying || !w.linked {
+ w.retrying = false
+ w.mutex.Unlock()
+
+ return false
}
- select {
- case w.requests <- request:
- default:
- answer("Too many WhatsApp commands wait. Try again in a minute.")
+ first := w.startOutage()
+ cause := whatsappRedact(w.own, err.Error())
+ w.mutex.Unlock()
+
+ if first {
+ w.logger.Warn("first connect failed, retrying", "err", cause)
}
+
+ return true
}
-// Status returns the state of the link: not linked, linked and connected, or
-// linked and disconnected, with the times of the connection and the routes.
-// Route source IDs can hold the phone numbers of other chats.
-func (w *WhatsApp) Status() string {
+// close ends the connection and the timer of the notice, and stops all notices.
+func (w *WhatsApp) close() {
w.mutex.Lock()
- defer w.mutex.Unlock()
- routes := ""
- if w.routes != nil {
- listed := w.routes.List()
- if listed != "No routes." {
- routes = "\nWhatsApp routes:\n" + listed
- }
+ w.closed = true
+ if w.timer != nil {
+ w.timer.Stop()
}
- switch {
- case !w.linked:
- return "WhatsApp is not linked. Run /whatsapp pair PHONE to link it." + routes
- case w.connected:
- return "WhatsApp is linked and connected since " + w.connectedAt.Format(whatsappTimeLayout) + "." + routes
- case w.disconnectedAt.IsZero():
- return "WhatsApp is linked and not connected yet." + routes
- case w.connectedAt.IsZero():
- return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + "." + routes
- default:
- return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + ". The last connection started at " + w.connectedAt.Format(whatsappTimeLayout) + "." + routes
+ client := w.client
+ w.client = nil
+ w.mutex.Unlock()
+
+ w.inbox.close()
+
+ if client != nil {
+ client.Disconnect()
}
}
-// BackupSession writes a consistent SQLite snapshot to path while the session
-// cannot be created, closed, or removed. The source opens without creation
-// permission. No session file means no linked state to copy.
-func (w *WhatsApp) BackupSession(ctx context.Context, path string) (err error) {
- w.sessionMutex.Lock()
- defer w.sessionMutex.Unlock()
-
+// 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()
- _, err = os.Stat(w.sessionPath())
- if errors.Is(err, os.ErrNotExist) {
- return nil
+ return WhatsAppLink{
+ linked: w.linked,
+ device: w.device,
+ client: w.client,
+ connected: w.connected,
+ own: slices.Clone(w.own),
}
+}
- if err != nil {
- return fmt.Errorf("read session file: %w", err)
+// loggedOut logs the open batches, deletes the session after a logout from the
+// phone, notes it in the log of l, and tells all clients one time.
+func (w *WhatsApp) loggedOut(ctx context.Context, l *TurnLoop) error {
+ if !w.isLinked() {
+ return nil
}
- err = os.MkdirAll(filepath.Dir(path), 0o700)
- if err != nil {
- return fmt.Errorf("create session snapshot directory: %w", err)
- }
+ w.logger.Error("logged out by the phone")
- address, err := whatsappSessionAddress(w.sessionPath())
+ err := w.inbox.deliver(ctx, l, w.link(), true)
if err != nil {
- return fmt.Errorf("find session database: %w", err)
+ return err
}
- database, err := sql.Open("sqlite", address+"&mode=rw")
+ err = w.removeSession()
if err != nil {
- return fmt.Errorf("open session snapshot source: %w", err)
+ w.logger.Error("session delete failed", "err", err)
}
- defer func() {
- err = errors.Join(err, database.Close())
- }()
-
- _, err = database.ExecContext(ctx, "VACUUM INTO ?", path)
+ err = l.note("WhatsApp was unlinked from the phone.")
if err != nil {
- return fmt.Errorf("snapshot session database: %w", err)
+ w.logger.Error("chat note failed", "err", err)
}
- err = os.Chmod(path, 0o600)
- if err != nil {
- return fmt.Errorf("set session snapshot mode: %w", err)
- }
+ w.broadcast("WhatsApp was unlinked from the phone. Run /whatsapp pair PHONE to link it again.")
return nil
}
-// Close closes the session database.
-func (w *WhatsApp) Close() error {
- w.sessionMutex.Lock()
- defer w.sessionMutex.Unlock()
-
+// unlink logs the open batches, logs out the linked device, and deletes the
+// session. When the logout call fails, as it does while WhatsApp is
+// disconnected, the session is deleted all the same, and answer tells the user
+// to remove the device in the phone. Its error is one of the log or the state
+// file.
+func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text string)) error {
w.mutex.Lock()
- defer w.mutex.Unlock()
- if w.container == nil {
+ linked := w.linked
+ client := w.client
+ w.mutex.Unlock()
+
+ if !linked {
+ answer("WhatsApp is not linked.")
+
return nil
}
- err := w.container.Close()
- w.container = nil
-
- return err
-}
+ err := w.inbox.deliver(ctx, l, w.link(), true)
+ if err != nil {
+ 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)
-}
+ var logoutErr error
-// Send returns the answer of whatsapp_send for text and an optional stored
-// image to chat, with the durable send id that whatsappSend describes.
-func (w *WhatsApp) Send(ctx context.Context, id, chat, text, image string) (string, error) {
- return whatsappSend(ctx, w.link(), w.groups, w.state, w.inbox.images, w.logger, w.clock, id, chat, text, image)
-}
+ if client != nil {
+ ctx, cancel := context.WithTimeout(ctx, whatsappLogoutTimeout)
+ defer cancel()
-func (w *WhatsApp) sessionPath() string {
- return filepath.Join(w.dir, whatsappSessionFileName)
-}
+ logoutErr = client.Logout(ctx)
+ }
-// openSession opens the session database and its first device. The caller holds
-// the mutex, or no other goroutine uses w yet.
-func (w *WhatsApp) openSession(ctx context.Context) error {
- address, err := whatsappSessionAddress(w.sessionPath())
+ err = w.removeSession()
if err != nil {
- return err
+ answer("The unlink failed: " + w.redact(err.Error()))
+
+ return nil
}
- container, err := sqlstore.New(ctx, "sqlite", address, waLog.Noop)
+ w.logger.Info("unlinked")
+
+ err = l.note("WhatsApp is unlinked.")
if err != nil {
- return fmt.Errorf("open WhatsApp session: %w", err)
+ w.logger.Error("chat note failed", "err", err)
}
- device, err := container.GetFirstDevice(ctx)
- if err != nil {
- return errors.Join(fmt.Errorf("read WhatsApp device: %w", err), container.Close())
+ if client == nil || logoutErr != nil {
+ answer("WhatsApp is unlinked here, but the phone did not get the logout. In WhatsApp on your phone, open Linked devices and remove the device.")
+
+ return nil
}
- w.container = container
- w.device = device
+ answer("WhatsApp is unlinked.")
return nil
}
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 tries the starting connection of the linked device with one client.
-// It returns whether Run must retry. A failed call starts the shared outage
-// state and logs its redacted cause once, because a returned ConnectContext
-// error does not emit Disconnected. Connected logs recovery after
-// authentication.
-func (w *WhatsApp) connect(ctx context.Context) bool {
- w.mutex.Lock()
-
- if ctx.Err() != nil || !w.linked || !w.retrying || w.closed {
- w.mutex.Unlock()
-
- return false
- }
-
- if w.client == nil {
- w.client = w.newClient(w.device, w.handle)
- }
-
- client := w.client
- w.mutex.Unlock()
-
- err := client.ConnectContext(ctx)
-
- w.mutex.Lock()
-
- if err == nil || ctx.Err() != nil || !w.retrying || !w.linked {
- w.retrying = false
- w.mutex.Unlock()
-
- return false
- }
-
- first := w.startOutage()
- cause := whatsappRedact(w.own, err.Error())
- w.mutex.Unlock()
-
- if first {
- w.logger.Warn("first connect failed, retrying", "err", cause)
- }
-
- return true
-}
-
-// startOutage starts the shared outage time and notice timer once. The caller
-// holds the mutex. Its result tells whether this is the first failure.
-func (w *WhatsApp) startOutage() bool {
- first := w.linked && (w.connected || w.disconnectedAt.IsZero())
- if first {
- w.disconnectedAt = w.clock()
- w.disconnections++
- disconnections := w.disconnections
- w.timer = time.AfterFunc(whatsappOutageNoticeDelay, func() {
- w.disconnectedLong(disconnections)
- })
- }
-
- w.connected = false
-
- return first
-}
-
-// close ends the connection and the timer of the notice, and stops all notices.
-func (w *WhatsApp) close() {
- w.mutex.Lock()
-
- w.closed = true
- if w.timer != nil {
- w.timer.Stop()
- }
-
- client := w.client
- w.client = nil
- w.mutex.Unlock()
-
- w.inbox.close()
-
- if client != nil {
- client.Disconnect()
- }
-}
-
-// loggedOut logs the open batches, deletes the session after a logout from the
-// phone, notes it in the log of l, and tells all clients one time.
-func (w *WhatsApp) loggedOut(ctx context.Context, l *TurnLoop) error {
- if !w.isLinked() {
- return nil
- }
-
- w.logger.Error("logged out by the phone, run /whatsapp pair PHONE")
-
- err := w.inbox.deliver(ctx, l, w.link(), true)
- if err != nil {
- return err
- }
-
- err = w.removeSession()
- if err != nil {
- w.logger.Error("session delete failed", "err", err)
- }
-
- err = l.note("WhatsApp was unlinked from the phone.")
- if err != nil {
- w.logger.Error("chat note failed", "err", err)
- }
-
- w.broadcast("WhatsApp was unlinked from the phone. Run /whatsapp pair PHONE to link it again.")
-
- return nil
-}
-
-// unlink logs the open batches, logs out the linked device, and deletes the
-// session. When the logout call fails, as it does while WhatsApp is
-// disconnected, the session is deleted all the same, and answer tells the user
-// to remove the device in the phone. Its error is one of the log or the state
-// file.
-func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text string)) error {
- w.mutex.Lock()
-
- linked := w.linked
- client := w.client
- w.mutex.Unlock()
-
- if !linked {
- answer("WhatsApp is not linked.")
-
- return nil
- }
-
- err := w.inbox.deliver(ctx, l, w.link(), true)
- if err != nil {
- return err
- }
-
- var logoutErr error
-
- if client != nil {
- ctx, cancel := context.WithTimeout(ctx, whatsappLogoutTimeout)
- defer cancel()
-
- logoutErr = client.Logout(ctx)
- }
-
- err = w.removeSession()
- if err != nil {
- answer("The unlink failed: " + w.redact(err.Error()))
-
- return nil
- }
-
- w.logger.Info("unlinked")
-
- err = l.note("WhatsApp is unlinked.")
- if err != nil {
- w.logger.Error("chat note failed", "err", err)
- }
-
- if client == nil || logoutErr != nil {
- answer("WhatsApp is unlinked here, but the phone did not get the logout. In WhatsApp on your phone, open Linked devices and remove the device.")
-
- return nil
- }
-
- answer("WhatsApp is unlinked.")
-
- return nil
-}
-
-// removeSession ends the connection, closes the session database, and deletes
-// its files. The session lock covers the cleanup after mutex is released, so a
-// snapshot or a new session cannot open files that cleanup removes.
-func (w *WhatsApp) removeSession() error {
- w.sessionMutex.Lock()
- defer w.sessionMutex.Unlock()
-
- w.mutex.Lock()
-
- client := w.client
- w.client = nil
- w.linked = false
- w.retrying = false
- w.own = nil
- w.connected = false
- w.disconnectedAt = time.Time{}
- if w.timer != nil {
- w.timer.Stop()
- }
-
- w.noticed = false
- container := w.container
- w.container = nil
- w.device = nil
- w.mutex.Unlock()
-
- w.inbox.stopCatchup()
-
- if client != nil {
- client.Disconnect()
- }
-
- var err error
-
- if container != nil {
- err = container.Close()
- }
-
- for _, suffix := range []string{"", "-wal", "-shm"} {
- removeErr := os.Remove(w.sessionPath() + suffix)
- if !errors.Is(removeErr, os.ErrNotExist) {
- err = errors.Join(err, removeErr)
- }
- }
-
- return err
-}
-
// pair links a new device to the account of phone with a pairing code. It gives
// the steps and the code to answer, and then the end of the pairing: the linked
// number, an expired code, or a failure. A pairing that a new one ends gives no
}
}
-// newDevice returns a new device in the session database. It makes the
-// directory and the file of the session first, with their modes, when they do
-// not exist.
-func (w *WhatsApp) newDevice(ctx context.Context) (*store.Device, error) {
- w.sessionMutex.Lock()
- defer w.sessionMutex.Unlock()
-
+// handle takes one event of the library and tells if it was handled. A message
+// 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 whatsappCatchupTimeout. A disconnection keeps the time of its start
+// while Run or the library tries again. After whatsappOutageNoticeDelay 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.
+//
+// The log of the daemon gets one line for each change of the connection of the
+// linked device: connected, a failed first connection, a lost connection, and
+// connected again with the length of the outage. The retries of the library and
+// Run between them log nothing.
+func (w *WhatsApp) handle(event any) bool {
w.mutex.Lock()
- defer w.mutex.Unlock()
- if w.container == nil {
- err := os.MkdirAll(w.dir, 0o700)
- if err != nil {
- return nil, fmt.Errorf("create WhatsApp directory: %w", err)
+ ctx := w.ctx
+ w.mutex.Unlock()
+
+ switch event := event.(type) {
+ case *events.Message:
+ return w.inbox.receive(ctx, w.link(), event)
+ case *events.GroupInfo:
+ if event.Name != nil {
+ w.groups.set(event.JID.String(), event.Name.Name)
}
- file, err := os.OpenFile(w.sessionPath(), os.O_CREATE|os.O_RDWR, 0o600)
+ return true
+ case *events.JoinedGroup:
+ 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, line string
+
+ level := slog.LevelInfo
+
+ var attrs []any
+
+ w.mutex.Lock()
+
+ switch event := event.(type) {
+ case *events.PairSuccess:
+ w.own = whatsappOwnIDs(event.ID.User, event.LID.User)
+ case *events.Connected:
+ switch {
+ case !w.linked || w.connected:
+ case w.disconnectedAt.IsZero():
+ line = "connected"
+ default:
+ line = "connected again"
+ attrs = append(attrs, "after", w.clock().Sub(w.disconnectedAt))
+ }
+
+ w.connected = true
+ w.retrying = false
+ w.connectedAt = w.clock()
+ if w.timer != nil {
+ w.timer.Stop()
+ }
+
+ if w.noticed {
+ w.noticed = false
+ notice = "WhatsApp is connected again."
+ }
+ case *events.Disconnected:
+ connected := w.connected
+ if w.startOutage() {
+ level = slog.LevelWarn
+ line = "disconnected, retrying"
+ if !connected {
+ line = "first connect failed, retrying"
+ }
+ }
+ case *events.LoggedOut:
+ w.retrying = false
+ select {
+ case w.logouts <- struct{}{}:
+ default:
+ }
+ case events.PermanentDisconnect:
+ w.retrying = false
+ if w.linked {
+ w.connected = false
+ w.noticed = true
+ notice = "WhatsApp is disconnected and does not connect again by itself: " + event.PermanentDisconnectDescription() + ". Restart the daemon to try again."
+ level = slog.LevelError
+ line = "disconnected for good, restart to retry"
+ attrs = append(attrs, "err", whatsappRedact(w.own, event.PermanentDisconnectDescription()))
+ }
+ }
+
+ if w.closed {
+ line = ""
+ }
+
+ w.mutex.Unlock()
+
+ if line != "" {
+ w.logger.Log(ctx, level, line, attrs...)
+ }
+
+ if notice != "" {
+ w.broadcast(notice)
+ }
+
+ return true
+}
+
+// startOutage starts the shared outage time and notice timer once. The caller
+// holds the mutex. Its result tells whether this is the first failure.
+func (w *WhatsApp) startOutage() bool {
+ first := w.linked && (w.connected || w.disconnectedAt.IsZero())
+ if first {
+ w.disconnectedAt = w.clock()
+ w.disconnections++
+ disconnections := w.disconnections
+ w.timer = time.AfterFunc(whatsappOutageNoticeDelay, func() {
+ w.disconnectedLong(disconnections)
+ })
+ }
+
+ w.connected = false
+
+ return first
+}
+
+// removeSession ends the connection, closes the session database, and deletes
+// its files. The session lock covers the cleanup after mutex is released, so a
+// snapshot or a new session cannot open files that cleanup removes.
+func (w *WhatsApp) removeSession() error {
+ w.sessionMutex.Lock()
+ defer w.sessionMutex.Unlock()
+
+ w.mutex.Lock()
+
+ client := w.client
+ w.client = nil
+ w.linked = false
+ w.retrying = false
+ w.own = nil
+ w.connected = false
+ w.disconnectedAt = time.Time{}
+ if w.timer != nil {
+ w.timer.Stop()
+ }
+
+ w.noticed = false
+ container := w.container
+ w.container = nil
+ w.device = nil
+ w.mutex.Unlock()
+
+ w.inbox.stopCatchup()
+
+ if client != nil {
+ client.Disconnect()
+ }
+
+ var err error
+
+ if container != nil {
+ err = container.Close()
+ }
+
+ for _, suffix := range []string{"", "-wal", "-shm"} {
+ removeErr := os.Remove(w.sessionPath() + suffix)
+ if !errors.Is(removeErr, os.ErrNotExist) {
+ err = errors.Join(err, removeErr)
+ }
+ }
+
+ return err
+}
+
+// broadcast gives text as a notice to all clients.
+func (w *WhatsApp) broadcast(text string) {
+ w.mutex.Lock()
+
+ notify := w.notify
+ closed := w.closed
+ w.mutex.Unlock()
+
+ if notify != nil && !closed {
+ notify(Event{
+ Kind: "notice",
+ Text: text,
+ })
+ }
+}
+
+// redact replaces the own number and the own LID in text with OWN.
+func (w *WhatsApp) redact(text string) string {
+ w.mutex.Lock()
+ defer w.mutex.Unlock()
+
+ return whatsappRedact(w.own, text)
+}
+
+// newDevice returns a new device in the session database. It makes the
+// directory and the file of the session first, with their modes, when they do
+// not exist.
+func (w *WhatsApp) newDevice(ctx context.Context) (*store.Device, error) {
+ w.sessionMutex.Lock()
+ defer w.sessionMutex.Unlock()
+
+ w.mutex.Lock()
+ defer w.mutex.Unlock()
+
+ if w.container == nil {
+ err := os.MkdirAll(w.dir, 0o700)
+ if err != nil {
+ return nil, fmt.Errorf("create WhatsApp directory: %w", err)
+ }
+
+ file, err := os.OpenFile(w.sessionPath(), os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, fmt.Errorf("create WhatsApp session: %w", err)
}
w.linked = true
}
-// handle takes one event of the library and tells if it was handled. A message
-// 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 whatsappCatchupTimeout. A disconnection keeps the time of its start
-// while Run or the library tries again. After whatsappOutageNoticeDelay 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.
-//
-// The log of the daemon gets one line for each change of the connection of the
-// linked device: connected, a failed first connection, a lost connection, and
-// connected again with the length of the outage. The retries of the library and
-// Run between them log nothing.
-func (w *WhatsApp) handle(event any) bool {
+// disconnectedLong gives the notice of a long disconnection, unless the
+// disconnection with the number disconnections ended.
+func (w *WhatsApp) disconnectedLong(disconnections int) {
w.mutex.Lock()
- ctx := w.ctx
- w.mutex.Unlock()
+ long := !w.closed && w.linked && !w.connected && !w.noticed && w.disconnections == disconnections
+ if long {
+ w.noticed = true
+ }
+
+ since := w.disconnectedAt
+ w.mutex.Unlock()
+
+ if long {
+ w.broadcast("WhatsApp is disconnected since " + since.Format(whatsappTimeLayout) + ". OneChat tries again.")
+ }
+}
+
+// Command runs the WhatsApp command args, the text after /whatsapp, and gives
+// each answer to answer. The answers go only to the client that sent the
+// command.
+func (w *WhatsApp) Command(args string, answer func(text string)) {
+ fields := strings.Fields(args)
+ request := WhatsAppRequest{
+ answer: answer,
+ }
+
+ switch {
+ case len(fields) == 1 && fields[0] == "status":
+ answer(w.Status())
+
+ return
+ case len(fields) == 1 && fields[0] == "unlink":
+ request.unlink = true
+ case len(fields) >= 2 && fields[0] == "pair":
+ phone, valid := whatsappPhone(strings.Join(fields[1:], ""))
+ if !valid {
+ answer("PHONE must be the number of your WhatsApp account with its country code: 7 to 15 digits, such as +15550001111.")
+
+ return
+ }
+
+ request.phone = phone
+ default:
+ answer(whatsappUsage)
+
+ return
+ }
+
+ select {
+ case w.requests <- request:
+ default:
+ answer("Too many WhatsApp commands wait. Try again in a minute.")
+ }
+}
+
+// Status returns the state of the link: not linked, linked and connected, or
+// linked and disconnected, with the times of the connection and the routes.
+// Route source IDs can hold the phone numbers of other chats.
+func (w *WhatsApp) Status() string {
+ w.mutex.Lock()
+ defer w.mutex.Unlock()
+
+ routes := ""
+ if w.routes != nil {
+ listed := w.routes.List()
+ if listed != "No routes." {
+ routes = "\nWhatsApp routes:\n" + listed
+ }
+ }
+
+ switch {
+ case !w.linked:
+ return "WhatsApp is not linked. Run /whatsapp pair PHONE to link it." + routes
+ case w.connected:
+ return "WhatsApp is linked and connected since " + w.connectedAt.Format(whatsappTimeLayout) + "." + routes
+ case w.disconnectedAt.IsZero():
+ return "WhatsApp is linked and not connected yet." + routes
+ case w.connectedAt.IsZero():
+ return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + "." + routes
+ default:
+ return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + ". The last connection started at " + w.connectedAt.Format(whatsappTimeLayout) + "." + routes
+ }
+}
+
+// BackupSession writes a consistent SQLite snapshot to path while the session
+// cannot be created, closed, or removed. The source opens without creation
+// permission. No session file means no linked state to copy.
+func (w *WhatsApp) BackupSession(ctx context.Context, path string) (err error) {
+ w.sessionMutex.Lock()
+ defer w.sessionMutex.Unlock()
+
+ w.mutex.Lock()
+ defer w.mutex.Unlock()
+
+ _, err = os.Stat(w.sessionPath())
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+
+ if err != nil {
+ return fmt.Errorf("read session file: %w", err)
+ }
+
+ err = os.MkdirAll(filepath.Dir(path), 0o700)
+ if err != nil {
+ return fmt.Errorf("create session snapshot directory: %w", err)
+ }
- switch event := event.(type) {
- case *events.Message:
- return w.inbox.receive(ctx, w.link(), event)
- case *events.GroupInfo:
- if event.Name != nil {
- w.groups.set(event.JID.String(), event.Name.Name)
- }
+ address, err := whatsappSessionAddress(w.sessionPath())
+ if err != nil {
+ return fmt.Errorf("find session database: %w", err)
+ }
- return true
- case *events.JoinedGroup:
- w.groups.set(event.JID.String(), event.Name)
+ database, err := sql.Open("sqlite", address+"&mode=rw")
+ if err != nil {
+ return fmt.Errorf("open session snapshot source: %w", err)
+ }
- return true
- case *events.OfflineSyncCompleted:
- w.inbox.endCatchup()
+ defer func() {
+ err = errors.Join(err, database.Close())
+ }()
- return true
- case *events.Connected:
- w.inbox.startCatchup()
+ _, err = database.ExecContext(ctx, "VACUUM INTO ?", path)
+ if err != nil {
+ return fmt.Errorf("snapshot session database: %w", err)
}
- var notice, line string
+ err = os.Chmod(path, 0o600)
+ if err != nil {
+ return fmt.Errorf("set session snapshot mode: %w", err)
+ }
- level := slog.LevelInfo
+ return nil
+}
- var attrs []any
+// Close closes the session database.
+func (w *WhatsApp) Close() error {
+ w.sessionMutex.Lock()
+ defer w.sessionMutex.Unlock()
w.mutex.Lock()
+ defer w.mutex.Unlock()
- switch event := event.(type) {
- case *events.PairSuccess:
- w.own = whatsappOwnIDs(event.ID.User, event.LID.User)
- case *events.Connected:
- switch {
- case !w.linked || w.connected:
- case w.disconnectedAt.IsZero():
- line = "connected"
- default:
- line = "connected again"
- attrs = append(attrs, "after", w.clock().Sub(w.disconnectedAt))
- }
+ if w.container == nil {
+ return nil
+ }
- w.connected = true
- w.retrying = false
- w.connectedAt = w.clock()
- if w.timer != nil {
- w.timer.Stop()
- }
+ err := w.container.Close()
+ w.container = nil
- if w.noticed {
- w.noticed = false
- notice = "WhatsApp is connected again."
- }
- case *events.Disconnected:
- connected := w.connected
- if w.startOutage() {
- level = slog.LevelWarn
- line = "disconnected, retrying"
- if !connected {
- line = "first connect failed, retrying"
- }
- }
- case *events.LoggedOut:
- w.retrying = false
- select {
- case w.logouts <- struct{}{}:
- default:
- }
- case events.PermanentDisconnect:
- w.retrying = false
- if w.linked {
- w.connected = false
- w.noticed = true
- notice = "WhatsApp is disconnected and does not connect again by itself: " + event.PermanentDisconnectDescription() + ". Restart the daemon to try again."
- level = slog.LevelError
- line = "disconnected for good, restart to retry"
- attrs = append(attrs, "err", whatsappRedact(w.own, event.PermanentDisconnectDescription()))
- }
- }
+ return err
+}
- if w.closed {
- line = ""
- }
+// 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)
+}
- w.mutex.Unlock()
+// Send returns the answer of whatsapp_send for text and an optional stored
+// image to chat, with the durable send id that whatsappSend describes.
+func (w *WhatsApp) Send(ctx context.Context, id, chat, text, image string) (string, error) {
+ return whatsappSend(ctx, w.link(), w.groups, w.state, w.inbox.images, w.logger, w.clock, id, chat, text, image)
+}
- if line != "" {
- w.logger.Log(ctx, level, line, attrs...)
- }
+// whatsappOwnIDs returns the own number and the own LID that are not empty.
+func whatsappOwnIDs(ids ...string) []string {
+ var own []string
- if notice != "" {
- w.broadcast(notice)
+ for _, id := range ids {
+ if id != "" {
+ own = append(own, id)
+ }
}
- return true
+ return own
}
-// disconnectedLong gives the notice of a long disconnection, unless the
-// disconnection with the number disconnections ended.
-func (w *WhatsApp) disconnectedLong(disconnections int) {
- w.mutex.Lock()
-
- long := !w.closed && w.linked && !w.connected && !w.noticed && w.disconnections == disconnections
- if long {
- w.noticed = true
+// 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.
+func whatsappSessionAddress(path string) (string, error) {
+ path, err := filepath.Abs(path)
+ if err != nil {
+ return "", fmt.Errorf("find WhatsApp session: %w", err)
}
- since := w.disconnectedAt
- w.mutex.Unlock()
-
- if long {
- w.broadcast("WhatsApp is disconnected since " + since.Format(whatsappTimeLayout) + ". OneChat tries again.")
+ address := url.URL{
+ Scheme: "file",
+ Path: path,
+ RawQuery: "_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)",
}
+
+ return address.String(), nil
}
-// broadcast gives text as a notice to all clients.
-func (w *WhatsApp) broadcast(text string) {
- w.mutex.Lock()
+// 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")
+ }
- notify := w.notify
- closed := w.closed
- w.mutex.Unlock()
+ return text
+}
- if notify != nil && !closed {
- notify(Event{
- Kind: "notice",
- Text: text,
- })
+// whatsappItemError returns the error of a pairing item that is not a code, a
+// success, or a timeout.
+func whatsappItemError(item whatsmeow.QRChannelItem) string {
+ if item.Error != nil {
+ return item.Error.Error()
}
+
+ return item.Event
}
-// redact replaces the own number and the own LID in text with OWN.
-func (w *WhatsApp) redact(text string) string {
- w.mutex.Lock()
- defer w.mutex.Unlock()
+// whatsappPhone returns the digits of phone, a number with its country code and
+// an optional + and dashes, and tells if it has the form of a number: 7 to 15
+// digits, with no 0 first.
+func whatsappPhone(phone string) (string, bool) {
+ phone = strings.ReplaceAll(strings.TrimPrefix(phone, "+"), "-", "")
+ valid := len(phone) >= 7 && len(phone) <= 15 && phone[0] != '0' && strings.Trim(phone, "0123456789") == ""
- return whatsappRedact(w.own, text)
+ return phone, valid
}
// whatsappSetDeviceProps sets the device properties that the library sends at
}
}
-// whatsappOwnIDs returns the own number and the own LID that are not empty.
-func whatsappOwnIDs(ids ...string) []string {
- var own []string
-
- for _, id := range ids {
- if id != "" {
- own = append(own, id)
- }
- }
-
- return own
-}
-
// whatsappChatHash returns a short hash of chat for the daemon log, so that a
// log line can tell chats apart without a phone number.
func whatsappChatHash(chat types.JID) string {
return fmt.Sprintf("%x", digest[:6])
}
-// 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.
-func whatsappSessionAddress(path string) (string, error) {
- path, err := filepath.Abs(path)
- if err != nil {
- return "", fmt.Errorf("find WhatsApp session: %w", err)
- }
-
- address := url.URL{
- Scheme: "file",
- Path: path,
- RawQuery: "_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)",
- }
-
- return address.String(), nil
-}
-
// whatsappCommandArgs returns the text after /whatsapp of a line of a WhatsApp
// command, or false for any other line.
func whatsappCommandArgs(line string) (string, bool) {
return strings.TrimSpace(args), true
}
-
-// whatsappPhone returns the digits of phone, a number with its country code and
-// an optional + and dashes, and tells if it has the form of a number: 7 to 15
-// digits, with no 0 first.
-func whatsappPhone(phone string) (string, bool) {
- phone = strings.ReplaceAll(strings.TrimPrefix(phone, "+"), "-", "")
- valid := len(phone) >= 7 && len(phone) <= 15 && phone[0] != '0' && strings.Trim(phone, "0123456789") == ""
-
- return phone, valid
-}
-
-// whatsappItemError returns the error of a pairing item that is not a code, a
-// success, or a timeout.
-func whatsappItemError(item whatsmeow.QRChannelItem) string {
- if item.Error != nil {
- return item.Error.Error()
- }
-
- return item.Event
-}