import (
"context"
+ "crypto/sha256"
"database/sql"
"errors"
"fmt"
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
-// the session file, WhatsApp is not linked. The session gives full access to
-// the account, so a file that another user can read is an error. The clock
-// gives the times of the status, logger gets the failures, and newClient makes
-// the client of a device with its event handler.
-func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logger *slog.Logger, newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient) (*WhatsApp, error) {
- w := &WhatsApp{
- ctx: ctx,
- dir: filepath.Join(dir, whatsappDirName),
- clock: clock,
- logger: logger,
- newClient: newClient,
- requests: make(chan WhatsAppRequest, whatsappRequests),
- logouts: make(chan struct{}, 1),
- groups: newWhatsAppGroups(logger),
- }
-
- var err error
-
- w.state, err = openWhatsAppState(filepath.Join(w.dir, whatsappStateFileName))
- if err != nil {
- return nil, err
- }
-
- w.inbox = newWhatsAppInbox(w.state, clock, w.groups, &ImageStore{dir: filepath.Join(dir, imageDirName)}, logger)
-
- info, err := os.Stat(w.sessionPath())
- if errors.Is(err, os.ErrNotExist) {
- return w, nil
- }
-
- if err != nil {
- return nil, fmt.Errorf("read WhatsApp session: %w", err)
- }
-
- if info.Mode().Perm()&0o077 != 0 {
- return nil, fmt.Errorf("%s holds the WhatsApp session, so its mode must be 0600", filepath.Join(whatsappDirName, whatsappSessionFileName))
- }
-
- err = w.openSession(ctx)
- if err != nil {
- return nil, err
- }
-
- if w.device.ID != nil {
- w.linked = true
- w.inbox.waitForCatchup()
-
- w.own = whatsappOwnIDs(w.device.ID.User, w.device.LID.User)
- }
-
- return w, nil
-}
-
// WhatsApp is the link of the daemon to the WhatsApp account of the user, as a
// linked device. The session database of the library in dir holds the keys of
// the device. Run keeps a linked device connected, and the library connects
closed bool
}
+// 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 {
+ phone string
+ unlink bool
+ answer func(text string)
+}
+
+// openWhatsApp opens the WhatsApp session in the data directory dir. Without
+// the session file, WhatsApp is not linked. The session gives full access to
+// the account, so a file that another user can read is an error. The clock
+// gives the times of the status, logger gets the failures, and newClient makes
+// the client of a device with its event handler.
+func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logger *slog.Logger, newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient) (*WhatsApp, error) {
+ w := &WhatsApp{
+ ctx: ctx,
+ dir: filepath.Join(dir, whatsappDirName),
+ clock: clock,
+ logger: logger,
+ newClient: newClient,
+ requests: make(chan WhatsAppRequest, whatsappRequests),
+ logouts: make(chan struct{}, 1),
+ groups: newWhatsAppGroups(logger),
+ }
+
+ var err error
+
+ w.state, err = openWhatsAppState(filepath.Join(w.dir, whatsappStateFileName))
+ if err != nil {
+ return nil, err
+ }
+
+ w.inbox = newWhatsAppInbox(w.state, clock, w.groups, &ImageStore{dir: filepath.Join(dir, imageDirName)}, logger)
+
+ info, err := os.Stat(w.sessionPath())
+ if errors.Is(err, os.ErrNotExist) {
+ return w, nil
+ }
+
+ if err != nil {
+ return nil, fmt.Errorf("read WhatsApp session: %w", err)
+ }
+
+ if info.Mode().Perm()&0o077 != 0 {
+ return nil, fmt.Errorf("%s holds the WhatsApp session, so its mode must be 0600", filepath.Join(whatsappDirName, whatsappSessionFileName))
+ }
+
+ err = w.openSession(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ if w.device.ID != nil {
+ w.linked = true
+ w.inbox.waitForCatchup()
+
+ w.own = whatsappOwnIDs(w.device.ID.User, w.device.LID.User)
+ }
+
+ 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
var pairing sync.WaitGroup
- cancel := func() {}
+ cancel := func() {
+ }
defer func() {
cancel()
pairing.Wait()
cancel()
pairing.Wait()
- cancel = func() {}
+ cancel = func() {
+ }
switch {
case request.unlink:
}
}
-// 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()
- select {
- case w.requests <- request:
- default:
- answer("Too many WhatsApp commands wait. Try again in a minute.")
+ return false
}
-}
-// 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()
+ first := w.startOutage()
+ cause := whatsappRedact(w.own, err.Error())
+ w.mutex.Unlock()
- routes := ""
- if w.routes != nil {
- listed := w.routes.List()
- if listed != "No routes." {
- routes = "\nWhatsApp routes:\n" + listed
- }
+ if first {
+ w.logger.Warn("first connect failed, retrying", "err", cause)
}
- 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
- }
+ return true
}
-// 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()
-
+// 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()
- _, err = os.Stat(w.sessionPath())
- if errors.Is(err, os.ErrNotExist) {
- return nil
+ w.closed = true
+ if w.timer != nil {
+ w.timer.Stop()
}
- 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)
- }
-
- address, err := whatsappSessionAddress(w.sessionPath())
- if err != nil {
- return fmt.Errorf("find session database: %w", err)
- }
-
- database, err := sql.Open("sqlite", address+"&mode=rw")
- if err != nil {
- return fmt.Errorf("open session snapshot source: %w", err)
- }
-
- defer func() {
- err = errors.Join(err, database.Close())
- }()
-
- _, err = database.ExecContext(ctx, "VACUUM INTO ?", path)
- if err != nil {
- return fmt.Errorf("snapshot session database: %w", err)
- }
-
- err = os.Chmod(path, 0o600)
- if err != nil {
- return fmt.Errorf("set session snapshot mode: %w", err)
- }
-
- return nil
-}
-
-// Close closes the session database.
-func (w *WhatsApp) Close() error {
- w.sessionMutex.Lock()
- defer w.sessionMutex.Unlock()
-
- w.mutex.Lock()
- defer w.mutex.Unlock()
-
- if w.container == nil {
- return nil
- }
-
- err := w.container.Close()
- w.container = 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)
-}
-
-// 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.clock, id, chat, text, image)
-}
-
-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
- }
+ client := w.client
+ w.client = nil
+ w.mutex.Unlock()
- container, err := sqlstore.New(ctx, "sqlite", address, waLog.Noop)
- if err != nil {
- return fmt.Errorf("open WhatsApp session: %w", err)
- }
+ w.inbox.close()
- device, err := container.GetFirstDevice(ctx)
- if err != nil {
- return errors.Join(fmt.Errorf("read WhatsApp device: %w", err), container.Close())
+ if client != nil {
+ client.Disconnect()
}
-
- w.container = container
- w.device = device
-
- return nil
-}
-
-func (w *WhatsApp) isLinked() bool {
- w.mutex.Lock()
- defer w.mutex.Unlock()
-
- return w.linked
}
// link returns a copy of the link of the device: whether it is linked, the
}
}
-// 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 {
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()
-
+func (w *WhatsApp) isLinked() bool {
w.mutex.Lock()
+ defer w.mutex.Unlock()
- 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
+ return w.linked
}
// pair links a new device to the account of phone with a pairing code. It gives
}
}
-// 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)
- }
-
- 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)
- }
-
- err = file.Close()
- if err != nil {
- return nil, fmt.Errorf("create WhatsApp session: %w", err)
- }
-
- err = w.openSession(ctx)
- if err != nil {
- return nil, err
- }
- }
-
- w.device = w.container.NewDevice()
-
- return w.device, nil
-}
-
-// linkedBy makes client the client of the linked device.
-func (w *WhatsApp) linkedBy(client WhatsAppClient) {
- w.mutex.Lock()
- defer w.mutex.Unlock()
-
- w.client = client
- 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 {
- w.mutex.Lock()
-
- ctx := w.ctx
- w.mutex.Unlock()
+ ctx := w.ctx
+ w.mutex.Unlock()
switch event := event.(type) {
case *events.Message:
return true
}
-// disconnectedLong gives the notice of a long disconnection, unless the
-// disconnection with the number disconnections ended.
-func (w *WhatsApp) disconnectedLong(disconnections int) {
+// 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()
- long := !w.closed && w.linked && !w.connected && !w.noticed && w.disconnections == disconnections
- if long {
- w.noticed = true
+ 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()
}
- since := w.disconnectedAt
+ w.noticed = false
+ container := w.container
+ w.container = nil
+ w.device = nil
w.mutex.Unlock()
- if long {
- w.broadcast("WhatsApp is disconnected since " + since.Format(whatsappTimeLayout) + ". OneChat tries again.")
+ 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.
return whatsappRedact(w.own, 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
+// 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)
+ }
+
+ err = file.Close()
+ if err != nil {
+ return nil, fmt.Errorf("create WhatsApp session: %w", err)
+ }
+
+ err = w.openSession(ctx)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ w.device = w.container.NewDevice()
+
+ return w.device, nil
}
-// 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 {
- phone string
- unlink bool
- answer func(text string)
+// linkedBy makes client the client of the linked device.
+func (w *WhatsApp) linkedBy(client WhatsAppClient) {
+ w.mutex.Lock()
+ defer w.mutex.Unlock()
+
+ w.client = client
+ w.linked = true
+}
+
+// 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
+ }
+
+ 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)
+ }
+
+ address, err := whatsappSessionAddress(w.sessionPath())
+ if err != nil {
+ return fmt.Errorf("find session database: %w", err)
+ }
+
+ database, err := sql.Open("sqlite", address+"&mode=rw")
+ if err != nil {
+ return fmt.Errorf("open session snapshot source: %w", err)
+ }
+
+ defer func() {
+ err = errors.Join(err, database.Close())
+ }()
+
+ _, err = database.ExecContext(ctx, "VACUUM INTO ?", path)
+ if err != nil {
+ return fmt.Errorf("snapshot session database: %w", err)
+ }
+
+ err = os.Chmod(path, 0o600)
+ if err != nil {
+ return fmt.Errorf("set session snapshot mode: %w", err)
+ }
+
+ return nil
+}
+
+// Close closes the session database.
+func (w *WhatsApp) Close() error {
+ w.sessionMutex.Lock()
+ defer w.sessionMutex.Unlock()
+
+ w.mutex.Lock()
+ defer w.mutex.Unlock()
+
+ if w.container == nil {
+ return nil
+ }
+
+ err := w.container.Close()
+ w.container = 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)
+}
+
+// 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)
+}
+
+// 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
+}
+
+// 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
+}
+
+// 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
+}
+
+// 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
+}
+
+// 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
}
// 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
-}
-
-// 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)",
- }
+// 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 {
+ digest := sha256.Sum256([]byte(chat.ToNonAD().String()))
- return address.String(), nil
+ return fmt.Sprintf("%x", digest[:6])
}
// whatsappCommandArgs returns the text after /whatsapp of a line of a WhatsApp
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
-}