X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/790279faf0fda789d93a75f6f5996e65d5de4a18..effd32b0f2e4d146b219b1727601b0b7f6593638:/whatsapp.go diff --git a/whatsapp.go b/whatsapp.go index 95e1d54..851cfd1 100644 --- a/whatsapp.go +++ b/whatsapp.go @@ -2,6 +2,7 @@ package main import ( "context" + "database/sql" "errors" "fmt" "log/slog" @@ -163,7 +164,9 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge // of the client that asked. A logout from the phone comes as an event of the // library, and Run deletes the session. The mutex protects the session, the // client, and the state of the connection, which the event handler of the -// library changes from its own goroutines. +// library changes from its own goroutines. The sessionMutex serializes session +// creation, close, removal, and snapshots. It is held before mutex, and stays +// held when removal releases mutex for client callbacks. // // The inbox puts the text messages into batches and logs them, and groups // holds the names of the groups. Both get a WhatsAppLink from link for each @@ -187,7 +190,9 @@ type WhatsApp struct { state *WhatsAppState groups *WhatsAppGroups inbox *WhatsAppInbox + routes *Routes + sessionMutex sync.Mutex mutex sync.Mutex container *sqlstore.Container device *store.Device @@ -354,28 +359,90 @@ func (w *WhatsApp) Command(args string, answer func(text string)) { } // 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. +// 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." + 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) + "." + return "WhatsApp is linked and connected since " + w.connectedAt.Format(whatsappTimeLayout) + "." + routes case w.disconnectedAt.IsZero(): - return "WhatsApp is linked and not connected yet." + return "WhatsApp is linked and not connected yet." + routes case w.connectedAt.IsZero(): - return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + "." + 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) + "." + 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() @@ -618,8 +685,12 @@ func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text str } // removeSession ends the connection, closes the session database, and deletes -// its files. +// 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 @@ -773,6 +844,9 @@ func (w *WhatsApp) pair(ctx, runCtx context.Context, l *TurnLoop, phone string, // 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()