+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "go.mau.fi/whatsmeow"
+ "go.mau.fi/whatsmeow/store"
+ "go.mau.fi/whatsmeow/store/sqlstore"
+ "go.mau.fi/whatsmeow/types/events"
+ waLog "go.mau.fi/whatsmeow/util/log"
+
+ // The session store of the library opens SQLite with this pure Go
+ // driver.
+ _ "modernc.org/sqlite"
+)
+
+const (
+ // whatsappDirName is the directory of the WhatsApp files in the data
+ // directory.
+ whatsappDirName = "whatsapp"
+
+ // whatsappSessionFileName is the session database of the library in
+ // whatsappDirName.
+ whatsappSessionFileName = "session.db"
+
+ // whatsappCommand starts the input lines of the WhatsApp commands, such
+ // as "/whatsapp status".
+ whatsappCommand = "/whatsapp"
+
+ // whatsappUsage is the answer to a WhatsApp command that does not exist.
+ whatsappUsage = "Use /whatsapp status."
+
+ // whatsappTimeLayout is the layout of the times in the answers.
+ whatsappTimeLayout = "2006-01-02 15:04"
+)
+
+// WhatsAppClient is the part of the WhatsApp client that the daemon uses. The
+// real one is a *whatsmeow.Client from whatsappNewClient, and the tests use a
+// fake.
+type WhatsAppClient interface {
+ ConnectContext(ctx context.Context) error
+ Disconnect()
+}
+
+// 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 linked device with its event handler.
+func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logger *log.Logger, newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient) (*WhatsApp, error) {
+ w := &WhatsApp{
+ dir: filepath.Join(dir, whatsappDirName),
+ clock: clock,
+ logger: logger,
+ newClient: newClient,
+ }
+
+ path := filepath.Join(w.dir, whatsappSessionFileName)
+ info, err := os.Stat(path)
+ 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))
+ }
+
+ address, err := whatsappSessionAddress(path)
+ if err != nil {
+ return nil, err
+ }
+
+ w.container, err = sqlstore.New(ctx, "sqlite", address, waLog.Noop)
+ if err != nil {
+ return nil, fmt.Errorf("open WhatsApp session: %w", err)
+ }
+
+ w.device, err = w.container.GetFirstDevice(ctx)
+ if err != nil {
+ return nil, errors.Join(fmt.Errorf("read WhatsApp device: %w", err), w.container.Close())
+ }
+
+ 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. A device with an ID is linked, and Run keeps it connected.
+// The library connects again by itself after a network failure. The mutex
+// protects the state of the connection, which the event handler of the library
+// changes from its own goroutines.
+//
+// The library logs nothing, because its log lines hold the ID of the own
+// account, which is the phone number of the user.
+type WhatsApp struct {
+ dir string
+ clock func() time.Time
+ logger *log.Logger
+ newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient
+ container *sqlstore.Container
+ device *store.Device
+ client WhatsAppClient
+
+ mutex sync.Mutex
+ connected bool
+ connectedAt time.Time
+ disconnectedAt time.Time
+}
+
+// Run connects a linked device and keeps the connection until ctx ends. A
+// failed first connection is logged, and the library tries again when the
+// failure is one of the network.
+func (w *WhatsApp) Run(ctx context.Context) {
+ if !w.linked() {
+ <-ctx.Done()
+ return
+ }
+
+ w.client = w.newClient(w.device, w.handle)
+ err := w.client.ConnectContext(ctx)
+ if err != nil {
+ w.logger.Printf("connect: %v", err)
+ }
+
+ <-ctx.Done()
+ w.client.Disconnect()
+}
+
+// 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)) {
+ switch strings.TrimSpace(args) {
+ case "status":
+ answer(w.Status())
+ default:
+ answer(whatsappUsage)
+ }
+}
+
+// Status returns the state of the link: not linked, linked and connected, or
+// linked and disconnected, with the times of the connection. It never holds
+// the phone number.
+func (w *WhatsApp) Status() string {
+ w.mutex.Lock()
+ defer w.mutex.Unlock()
+
+ switch {
+ case !w.linked():
+ return "WhatsApp is not linked."
+ case w.connected:
+ return "WhatsApp is linked and connected since " + w.connectedAt.Format(whatsappTimeLayout) + "."
+ case w.disconnectedAt.IsZero():
+ return "WhatsApp is linked and not connected yet."
+ case w.connectedAt.IsZero():
+ return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + "."
+ default:
+ return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + ". The last connection started at " + w.connectedAt.Format(whatsappTimeLayout) + "."
+ }
+}
+
+// Close closes the session database.
+func (w *WhatsApp) Close() error {
+ if w.container == nil {
+ return nil
+ }
+
+ return w.container.Close()
+}
+
+// linked tells if the session holds a linked device.
+func (w *WhatsApp) linked() bool {
+ return w.device != nil && w.device.ID != nil
+}
+
+// handle takes one event of the library and tells if it was handled. A
+// disconnection keeps the time of its start while the library tries again.
+func (w *WhatsApp) handle(event any) bool {
+ w.mutex.Lock()
+ defer w.mutex.Unlock()
+
+ switch event.(type) {
+ case *events.Connected:
+ w.connected = true
+ w.connectedAt = w.clock()
+ case *events.Disconnected:
+ if w.connected || w.disconnectedAt.IsZero() {
+ w.disconnectedAt = w.clock()
+ }
+
+ w.connected = false
+ }
+
+ return true
+}
+
+// whatsappNewClient returns the client of the library for device, with handle
+// as its event handler. The client tries again after a failed first
+// connection, acknowledges a message only after handle returns true, and
+// downloads no old history.
+func whatsappNewClient(device *store.Device, handle func(event any) bool) WhatsAppClient {
+ client := whatsmeow.NewClient(device, waLog.Noop)
+ client.InitialAutoReconnect = true
+ client.SynchronousAck = true
+ client.ManualHistorySyncDownload = true
+ client.AddEventHandlerWithSuccessStatus(handle)
+
+ return client
+}
+
+// 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) {
+ line = strings.TrimSpace(line)
+ if line == whatsappCommand {
+ return "", true
+ }
+
+ args, found := strings.CutPrefix(line, whatsappCommand+" ")
+ if !found {
+ return "", false
+ }
+
+ return strings.TrimSpace(args), true
+}