X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/0da50dc187c8b4aa23ac1ddd0418c0da779389d9..80e348724041a694002fc38f57e8868b9b9a2be9:/whatsapp.go diff --git a/whatsapp.go b/whatsapp.go index f225756..ee785a3 100644 --- a/whatsapp.go +++ b/whatsapp.go @@ -2,6 +2,7 @@ package main import ( "context" + "crypto/sha256" "database/sql" "errors" "fmt" @@ -54,17 +55,17 @@ const ( // whatsappTimeLayout is the layout of the times in the answers. whatsappTimeLayout = "2006-01-02 15:04" - // whatsappPairTime is the longest time of a pairing. The library closes - // the pairing connection after about 160 seconds. - whatsappPairTime = 3 * time.Minute + // whatsappPairTimeout is the longest time of a pairing. The library + // closes the pairing connection after about 160 seconds. + whatsappPairTimeout = 3 * time.Minute - // whatsappLogoutTime is the longest time of the logout call of an + // whatsappLogoutTimeout is the longest time of the logout call of an // unlink. - whatsappLogoutTime = 30 * time.Second + whatsappLogoutTimeout = 30 * time.Second - // whatsappNoticeDelay is how long a disconnection lasts before the user - // gets a notice. - whatsappNoticeDelay = 10 * time.Minute + // whatsappOutageNoticeDelay is the delay from a disconnection to its + // notice to the user. + whatsappOutageNoticeDelay = 10 * time.Minute // whatsappRetryDelay is the first wait after a failed starting // connection. @@ -77,10 +78,6 @@ const ( // wait for Run. whatsappRequests = 8 - // whatsappLookupTime is the longest time of a read of the names in the - // session database or of the groups from WhatsApp. - whatsappLookupTime = 10 * time.Second - // 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." ) @@ -96,64 +93,11 @@ type WhatsAppClient interface { Logout(ctx context.Context) error GetJoinedGroups(ctx context.Context) ([]*types.GroupInfo, error) Download(ctx context.Context, message whatsmeow.DownloadableMessage) ([]byte, error) + Upload(ctx context.Context, data []byte, mediaType whatsmeow.MediaType) (whatsmeow.UploadResponse, 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 -// 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{ - dir: filepath.Join(dir, whatsappDirName), - clock: clock, - logger: logger, - newClient: newClient, - noticeDelay: whatsappNoticeDelay, - 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), whatsappBatchQuiet, whatsappBatchWindow) - 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 @@ -169,6 +113,9 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge // creation, close, removal, and snapshots. It is held before mutex, and stays // held when removal releases mutex for client callbacks. // +// ctx is the caller context until Run sets its context for library callbacks. +// The mutex protects it, so callbacks follow daemon cancellation. +// // The inbox puts messages into batches and logs them, and groups holds the // names of the groups. Both get a WhatsAppLink from link for each call, so they // use the current device and client. @@ -181,17 +128,17 @@ func openWhatsApp(ctx context.Context, dir string, clock func() time.Time, logge // log, and those of private chats hold phone numbers, because the agent needs // them to send. type WhatsApp struct { - dir string - clock func() time.Time - logger *slog.Logger - newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient - noticeDelay time.Duration - requests chan WhatsAppRequest - logouts chan struct{} - state *WhatsAppState - groups *WhatsAppGroups - inbox *WhatsAppInbox - routes *Routes + ctx context.Context + dir string + clock func() time.Time + logger *slog.Logger + newClient func(device *store.Device, handle func(event any) bool) WhatsAppClient + requests chan WhatsAppRequest + logouts chan struct{} + state *WhatsAppState + groups *WhatsAppGroups + inbox *WhatsAppInbox + routes *Routes sessionMutex sync.Mutex mutex sync.Mutex @@ -211,6 +158,78 @@ type WhatsApp struct { 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 +} + // 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 @@ -224,6 +243,7 @@ type WhatsApp struct { func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) error { w.mutex.Lock() + w.ctx = ctx w.notify = notify linked := w.linked w.retrying = linked @@ -309,7 +329,7 @@ func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) err case w.isLinked(): request.answer("WhatsApp is linked already. Run /whatsapp unlink first.") default: - pairCtx, cancelPair := context.WithTimeout(ctx, whatsappPairTime) + pairCtx, cancelPair := context.WithTimeout(ctx, whatsappPairTimeout) cancel = cancelPair pairing.Go(func() { defer cancelPair() @@ -462,10 +482,10 @@ 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 the send id of text to chat, as -// whatsappSend says. -func (w *WhatsApp) Send(ctx context.Context, id, chat, text string) (string, error) { - return whatsappSend(ctx, w.link(), w.groups, w.state, w.clock, id, chat, text) +// 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) } func (w *WhatsApp) sessionPath() string { @@ -569,7 +589,7 @@ func (w *WhatsApp) startOutage() bool { w.disconnectedAt = w.clock() w.disconnections++ disconnections := w.disconnections - w.timer = time.AfterFunc(w.noticeDelay, func() { + w.timer = time.AfterFunc(whatsappOutageNoticeDelay, func() { w.disconnectedLong(disconnections) }) } @@ -654,7 +674,7 @@ func (w *WhatsApp) unlink(ctx context.Context, l *TurnLoop, answer func(text str var logoutErr error if client != nil { - ctx, cancel := context.WithTimeout(ctx, whatsappLogoutTime) + ctx, cancel := context.WithTimeout(ctx, whatsappLogoutTimeout) defer cancel() logoutErr = client.Logout(ctx) @@ -890,19 +910,25 @@ func (w *WhatsApp) linkedBy(client WhatsAppClient) { // 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 its catchupLimit. A disconnection keeps the time of its start while -// Run or the library tries again. After noticeDelay 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. +// 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() + switch event := event.(type) { case *events.Message: - return w.inbox.receive(w.link(), event) + return w.inbox.receive(ctx, w.link(), event) case *events.GroupInfo: if event.Name != nil { w.groups.set(event.JID.String(), event.Name.Name) @@ -987,7 +1013,7 @@ func (w *WhatsApp) handle(event any) bool { w.mutex.Unlock() if line != "" { - w.logger.Log(context.Background(), level, line, attrs...) + w.logger.Log(ctx, level, line, attrs...) } if notice != "" { @@ -1039,24 +1065,6 @@ func (w *WhatsApp) redact(text string) string { 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 -} - -// 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) -} - // whatsappSetDeviceProps sets the device properties that the library sends at // the pairing. They are global values of the library, so the daemon sets them // once before any client exists. The phone shows the linked device with the @@ -1137,6 +1145,14 @@ func whatsappOwnIDs(ids ...string) []string { 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 { + digest := sha256.Sum256([]byte(chat.ToNonAD().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 {