removes the device in the phone, the daemon deletes the session and
tells all clients one time.
+*************************
+ Import WhatsApp history
+*************************
+
+Stop the daemon and finish all subagents before an import. Export each
+chat from WhatsApp as a ZIP with ``_chat.txt``. Keep the chat name in
+the ZIP name, such as ``WhatsApp Chat - Example.zip``. Read the plan
+first:
+
+.. code:: console
+
+ onechat import -n CHAT.zip OTHER.zip
+ onechat import CHAT.zip OTHER.zip
+
+``-n`` makes no model call and prints the chats, chat-day batches, local
+dates, cutoff, text bytes, merged message count, and estimated calls.
+The import keeps multiline text, system lines, and omitted-media
+markers. It maps the session's profile name to ``You``. Without that
+name, it keeps the senders as written and says so in the plan. An exact,
+unambiguous local contact or batch name supplies the chat ID when known.
+No network lookup is made.
+
+Each chat and local day becomes one work message marked ``(imported)``.
+Only messages before the first message of the earliest live WhatsApp
+batch enter the import. Live times have minute precision, so the cutoff
+excludes that whole minute. A chat-day already imported is skipped.
+Overlapping chat-days in the supplied ZIPs are refused. Review the
+exports before import: their text goes to the model as chat history.
+
+The import merges the full log in date order and builds its tree with
+real compactor calls, the agent prompt, and the agent cache key. It uses
+the probe's 660-call and 15-quota-point limits and prints the same usage
+report. Its estimate is about 1.3 calls per merged message, not a cost
+guarantee. A stopped or failed run leaves the agent unchanged. A new run
+starts over. Success keeps the other agent files and renames the old
+directory to ``agent-backup-TIMESTAMP`` before it installs the new one.
+Keep the backup until the new memory is checked, then start the daemon.
+Message IDs change to their positions in the merged log.
+
***********
Schedules
***********
Commands:
chat talk to the agent in this terminal (default)
daemon run the agent, Telegram, and WhatsApp
+ import [-n] ZIP... import WhatsApp history (stop the daemon first)
+ -n print the plan without model calls
login log in to ChatGPT with a device code
probe LOG COUNT [BUILDS] measure the compactor on a log, with real calls
help show this help
return daemon(ctx, os.Stdout)
case len(args) == 0 || len(args) == 1 && args[0] == "chat":
return terminal(ctx, os.Stdin, os.Stdout)
+ case len(args) > 0 && args[0] == "import":
+ return whatsappImport(ctx, args[1:], os.Stdout)
case len(args) > 0 && args[0] == "probe":
err := probe(ctx, args[1:], os.Stdout)
if err != nil {
--- /dev/null
+package main
+
+import (
+ "archive/zip"
+ "bufio"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "maps"
+ "os"
+ "path/filepath"
+ "regexp"
+ "slices"
+ "strings"
+ "time"
+
+ "go.mau.fi/whatsmeow/store/sqlstore"
+ waLog "go.mau.fi/whatsmeow/util/log"
+)
+
+const (
+ // whatsappImportMark marks history in the first line of a batch.
+ whatsappImportMark = " (imported)"
+
+ // whatsappImportBytes limits one export's uncompressed chat text.
+ whatsappImportBytes = 64 << 20
+)
+
+var (
+ whatsappImportStart = regexp.MustCompile(`^\[(\d{2}/\d{2}/\d{4}, \d{2}:\d{2}:\d{2})\] ([^\r\n]+?): ?(.*)$`)
+ whatsappImportHeader = regexp.MustCompile(`^\[WhatsApp\] (.*), (?:group|private chat) ([^ ,]+), [A-Za-z]+ (\d{4}-\d{2}-\d{2})$`)
+ whatsappImportUnknown = regexp.MustCompile(`^\[WhatsApp\] (.*), [A-Za-z]+ (\d{4}-\d{2}-\d{2})$`)
+)
+
+// whatsappImport runs the offline import command. It holds the same token lock
+// as the daemon and probe, even for a plan. The plan reads no credentials for a
+// model and makes no network call. Only a completed rebuild replaces the agent
+// directory, and unfinished subagents prevent either mode.
+func whatsappImport(ctx context.Context, args []string, out io.Writer) (err error) {
+ planOnly := len(args) > 0 && args[0] == "-n"
+ if planOnly {
+ args = args[1:]
+ }
+
+ if len(args) == 0 {
+ return errors.New("usage: onechat import [-n] ZIP [ZIP...]")
+ }
+
+ for _, arg := range args {
+ if strings.HasPrefix(arg, "-") {
+ return errors.New("usage: onechat import [-n] ZIP [ZIP...]")
+ }
+ }
+
+ dir, err := dataDir()
+ if err != nil {
+ return fmt.Errorf("find import data directory: %w", err)
+ }
+
+ tokens, err := lockTokens(dir)
+ if err != nil {
+ return fmt.Errorf("lock import tokens: %w", err)
+ }
+
+ defer func() {
+ closeErr := tokens.Close()
+ if closeErr != nil {
+ err = errors.Join(err, fmt.Errorf("close import token lock: %w", closeErr))
+ }
+ }()
+
+ root := filepath.Join(dir, "agent")
+ err = rebuildIdle(root)
+ if err != nil {
+ return fmt.Errorf("check import work: %w", err)
+ }
+
+ old, err := rebuildReadLog(ctx, root)
+ if err != nil {
+ return fmt.Errorf("read import log: %w", err)
+ }
+
+ names, owner, err := whatsappImportNames(ctx, dir)
+ if err != nil {
+ return fmt.Errorf("read import names: %w", err)
+ }
+
+ plan, err := whatsappImportPlan(ctx, args, old, names, owner, time.Local)
+ if err != nil {
+ return fmt.Errorf("plan import: %w", err)
+ }
+
+ err = plan.Write(out)
+ if err != nil {
+ return fmt.Errorf("write import plan: %w", err)
+ }
+
+ if planOnly || len(plan.added) == 0 {
+ return nil
+ }
+
+ // An existing agent is required so that a failed import never creates
+ // or changes the original agent's configuration.
+ _, err = os.Stat(filepath.Join(root, "config.json"))
+ if err != nil {
+ return fmt.Errorf("read agent config: %w", err)
+ }
+
+ agent, err := openAgent(root)
+ if err != nil {
+ return fmt.Errorf("open import agent: %w", err)
+ }
+
+ config, err := loadConfig(dir)
+ if err != nil {
+ return fmt.Errorf("load import configuration: %w", err)
+ }
+
+ address, auth, err := modelEndpoint(config, dir)
+ if err != nil {
+ return fmt.Errorf("select import endpoint: %w", err)
+ }
+
+ usage, err := os.CreateTemp(dir, ".import-usage-*.jsonl")
+ if err != nil {
+ return fmt.Errorf("create import usage: %w", err)
+ }
+
+ usagePath := usage.Name()
+ defer func() {
+ closeErr := os.Remove(usagePath)
+ if closeErr != nil {
+ err = errors.Join(err, fmt.Errorf("remove import usage: %w", closeErr))
+ }
+ }()
+
+ err = usage.Close()
+ if err != nil {
+ return fmt.Errorf("close import usage: %w", err)
+ }
+
+ client := newModelClient(address, auth, agent.CacheKey(), usagePath)
+ backup, err := rebuild(ctx, agent, plan.added, client, newCompactor(client, agent.Prompt, config.Compactor), out)
+ if err != nil {
+ return fmt.Errorf("rebuild imported memory: %w", err)
+ }
+
+ _, err = fmt.Fprintf(out, "import complete: backup %s\n", backup)
+ if err != nil {
+ return fmt.Errorf("write import result: %w", err)
+ }
+
+ return nil
+}
+
+// WhatsAppImportPlan holds the new chat-day batches and the size of the merged
+// log. Its estimate uses the measured cost of 1.3 calls per message, not a
+// promise about a particular export or compaction retry.
+type WhatsAppImportPlan struct {
+ added []DatedMessage
+ chats []string
+ cutoff time.Time
+ first time.Time
+ last time.Time
+ bytes int
+ messages int
+ skipped int
+ ownerKnown bool
+}
+
+// Write prints the plan without chat text or the owner's profile name.
+func (p WhatsAppImportPlan) Write(out io.Writer) error {
+ cutoff := "none (no live WhatsApp batch)"
+ if !p.cutoff.IsZero() {
+ cutoff = p.cutoff.Format(time.RFC3339)
+ }
+
+ dateRange := "none"
+ if !p.first.IsZero() {
+ dateRange = p.first.Format(time.RFC3339) + " to " + p.last.Format(time.RFC3339)
+ }
+
+ owner := "known (mapped to You)"
+ if !p.ownerKnown {
+ owner = "unknown (sender names kept as written)"
+ }
+
+ _, err := fmt.Fprintf(out, "chats: %d\n", len(p.chats))
+ if err != nil {
+ return fmt.Errorf("write import plan: %w", err)
+ }
+
+ for _, chat := range p.chats {
+ _, err = fmt.Fprintf(out, " %s\n", chat)
+ if err != nil {
+ return fmt.Errorf("write import plan: %w", err)
+ }
+ }
+
+ estimate := 1.3 * float64(p.messages)
+ if len(p.added) == 0 {
+ estimate = 0
+ }
+
+ lines := []string{
+ fmt.Sprintf("batches: %d (%d chat-days skipped)", len(p.added), p.skipped),
+ "dates: " + dateRange,
+ "cutoff: " + cutoff,
+ "owner: " + owner,
+ fmt.Sprintf("bytes: %d (new batch text)", p.bytes),
+ fmt.Sprintf("messages: %d (merged log)", p.messages),
+ fmt.Sprintf("estimated calls: %.0f (about 1.3 per message, limit %d)", estimate, buildCallLimit),
+ }
+
+ _, err = io.WriteString(out, strings.Join(lines, "\n")+"\n")
+ if err != nil {
+ return fmt.Errorf("write import plan: %w", err)
+ }
+
+ return nil
+}
+
+// whatsappImportNames reads only local session data. Group names that the
+// library keeps only in memory can also come from logged or pending batches.
+// Ambiguous names have no ID. A missing session keeps sender names unchanged.
+func whatsappImportNames(ctx context.Context, dir string) (names map[string]WhatsAppChat, owner string, err error) {
+ names = make(map[string]WhatsAppChat)
+ path := filepath.Join(dir, whatsappDirName, whatsappSessionFileName)
+ _, err = os.Stat(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return names, "", nil
+ }
+
+ if err != nil {
+ return nil, "", fmt.Errorf("read import session: %w", err)
+ }
+
+ address, err := whatsappSessionAddress(path)
+ if err != nil {
+ return nil, "", fmt.Errorf("find import session address: %w", err)
+ }
+
+ container, err := sqlstore.New(ctx, "sqlite", address, waLog.Noop)
+ if err != nil {
+ return nil, "", fmt.Errorf("open import session: %w", err)
+ }
+
+ defer func() {
+ closeErr := container.Close()
+ if closeErr != nil {
+ err = errors.Join(err, fmt.Errorf("close import session: %w", closeErr))
+ }
+ }()
+
+ device, err := container.GetFirstDevice(ctx)
+ if err != nil {
+ return nil, "", fmt.Errorf("read import device: %w", err)
+ }
+
+ if device.ID == nil {
+ return names, "", nil
+ }
+
+ owner = device.PushName
+ own := whatsappOwnIDs(device.ID.User, device.LID.User)
+ contacts, err := device.Contacts.GetAllContacts(ctx)
+ if err != nil {
+ return nil, "", fmt.Errorf("read import contacts: %w", err)
+ }
+
+ for jid, contact := range contacts {
+ if slices.Contains(own, jid.User) {
+ continue
+ }
+
+ for _, name := range []string{contact.FirstName, contact.FullName, contact.PushName, contact.BusinessName} {
+ if name != "" {
+ whatsappImportName(names, WhatsAppChat{
+ ID: jid.ToNonAD().String(),
+ Name: name,
+ Group: jid.Server == "g.us",
+ })
+ }
+ }
+ }
+
+ data, err := os.ReadFile(filepath.Join(dir, whatsappDirName, whatsappStateFileName))
+ if errors.Is(err, os.ErrNotExist) {
+ return names, owner, nil
+ }
+
+ if err != nil {
+ return nil, "", fmt.Errorf("read pending import names: %w", err)
+ }
+
+ var state WhatsAppStateFile
+ err = json.Unmarshal(data, &state)
+ if err != nil {
+ return nil, "", fmt.Errorf("decode pending import names: %w", err)
+ }
+
+ for _, batch := range state.Batches {
+ whatsappImportName(names, batch.Chat)
+ }
+
+ return names, owner, nil
+}
+
+// whatsappImportName adds an exact match unless distinct IDs share its name.
+func whatsappImportName(names map[string]WhatsAppChat, chat WhatsAppChat) {
+ old, found := names[chat.Name]
+ if found && old.ID != chat.ID {
+ chat.ID = ""
+ }
+
+ names[chat.Name] = chat
+}
+
+// whatsappImportPlan filters exports at the first message of the earliest live
+// batch and skips chat-days already imported. Each remaining chat-day becomes a
+// work message dated at its last exported message. Overlapping chat-days in the
+// inputs are refused, not silently combined or duplicated.
+func whatsappImportPlan(ctx context.Context, paths []string, old []DatedMessage, names map[string]WhatsAppChat, owner string, location *time.Location) (WhatsAppImportPlan, error) {
+ plan := WhatsAppImportPlan{ownerKnown: owner != ""}
+ names = maps.Clone(names)
+ if names == nil {
+ names = make(map[string]WhatsAppChat)
+ }
+
+ imported := make(map[string]bool)
+ for _, entry := range old {
+ if entry.Kind != "work" || !strings.HasPrefix(entry.Text, "[WhatsApp] ") {
+ continue
+ }
+
+ first, _, _ := strings.Cut(entry.Text, "\n")
+ marked := strings.HasSuffix(first, whatsappImportMark)
+ first = strings.TrimSuffix(first, whatsappImportMark)
+ match := whatsappImportHeader.FindStringSubmatch(first)
+ name, day := "", ""
+ if len(match) > 0 {
+ name, day = match[1], match[3]
+ whatsappImportName(names, WhatsAppChat{
+ ID: match[2],
+ Name: name,
+ Group: strings.Contains(first, ", group "+match[2]+","),
+ })
+ } else if marked {
+ match = whatsappImportUnknown.FindStringSubmatch(first)
+ if len(match) > 0 {
+ name, day = match[1], match[2]
+ }
+ }
+
+ if name == "" {
+ return plan, errors.New("read WhatsApp history: invalid batch header")
+ }
+
+ if marked {
+ imported[name+"\n"+day] = true
+ continue
+ }
+
+ _, body, found := strings.Cut(entry.Text, "\n")
+ if !found || len(body) < len("15:04 ") {
+ return plan, errors.New("read WhatsApp history: missing first message time")
+ }
+
+ date, err := time.ParseInLocation("2006-01-02 15:04", day+" "+body[:5], location)
+ if err != nil {
+ return plan, fmt.Errorf("parse first live message time: %w", err)
+ }
+
+ if plan.cutoff.IsZero() || date.Before(plan.cutoff) {
+ plan.cutoff = date
+ }
+ }
+
+ seen := make(map[string]bool)
+ for _, path := range paths {
+ if ctx.Err() != nil {
+ return plan, ctx.Err()
+ }
+
+ chat, messages, err := whatsappImportRead(path, owner, location)
+ if err != nil {
+ return plan, fmt.Errorf("read import export: %w", err)
+ }
+
+ known, found := names[chat.Name]
+ if found {
+ chat = known
+ }
+
+ if !slices.Contains(plan.chats, chat.Name) {
+ plan.chats = append(plan.chats, chat.Name)
+ }
+
+ days := make(map[string][]WhatsAppMessage)
+ for _, message := range messages {
+ if !plan.cutoff.IsZero() && !message.Time.Before(plan.cutoff) {
+ continue
+ }
+
+ day := message.Time.In(location).Format(time.DateOnly)
+ days[day] = append(days[day], message)
+ }
+
+ for _, day := range slices.Sorted(maps.Keys(days)) {
+ key := chat.Name + "\n" + day
+ if imported[key] {
+ plan.skipped++
+ continue
+ }
+
+ if seen[key] {
+ return plan, errors.New("import inputs overlap: give only one export for each chat and day")
+ }
+
+ seen[key] = true
+ batch := WhatsAppBatch{
+ Chat: chat,
+ Messages: days[day],
+ }
+
+ text := batch.Text(location)
+ first, body, _ := strings.Cut(text, "\n")
+ if chat.ID == "" {
+ first = "[WhatsApp] " + chat.Name + ", " + batch.Messages[0].Time.In(location).Format(whatsappBatchDayLayout)
+ }
+
+ text = first + whatsappImportMark + "\n" + body
+ plan.added = append(plan.added, DatedMessage{
+ Kind: "work",
+ Text: text,
+ Date: batch.Messages[len(batch.Messages)-1].Time.Format(time.RFC3339Nano),
+ })
+
+ plan.bytes += len(text)
+ firstDate, lastDate := batch.Messages[0].Time, batch.Messages[len(batch.Messages)-1].Time
+ if plan.first.IsZero() || firstDate.Before(plan.first) {
+ plan.first = firstDate
+ }
+
+ if plan.last.IsZero() || lastDate.After(plan.last) {
+ plan.last = lastDate
+ }
+ }
+ }
+
+ var err error
+ plan.added, err = rebuildMerge(nil, plan.added)
+ if err != nil {
+ return plan, fmt.Errorf("order import batches: %w", err)
+ }
+
+ plan.messages = len(old) + len(plan.added)
+ slices.Sort(plan.chats)
+
+ return plan, nil
+}
+
+// whatsappImportRead reads the single _chat.txt in an export without extracting
+// archive paths. Non-start lines continue the preceding message, and all
+// invisible left-to-right marks are removed. Dates use location. Media and
+// system messages remain text, including the written sender.
+func whatsappImportRead(path, owner string, location *time.Location) (chat WhatsAppChat, messages []WhatsAppMessage, err error) {
+ chat.Name = strings.TrimPrefix(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)), "WhatsApp Chat - ")
+ if chat.Name == "" || strings.ContainsAny(chat.Name, "\r\n") {
+ return chat, nil, errors.New("read export: chat name must be one nonempty line")
+ }
+
+ archive, err := zip.OpenReader(path)
+ if err != nil {
+ return chat, nil, fmt.Errorf("open export: %w", err)
+ }
+
+ defer func() {
+ closeErr := archive.Close()
+ if closeErr != nil {
+ err = errors.Join(err, fmt.Errorf("close export: %w", closeErr))
+ }
+ }()
+
+ var file *zip.File
+ for _, entry := range archive.File {
+ if filepath.Base(entry.Name) == "_chat.txt" && !entry.FileInfo().IsDir() {
+ if file != nil {
+ return chat, nil, errors.New("read export: more than one _chat.txt")
+ }
+
+ file = entry
+ }
+ }
+
+ if file == nil {
+ return chat, nil, errors.New("read export: _chat.txt is missing")
+ }
+
+ if file.UncompressedSize64 > whatsappImportBytes {
+ return chat, nil, errors.New("read export: chat text exceeds 64 MiB")
+ }
+
+ reader, err := file.Open()
+ if err != nil {
+ return chat, nil, fmt.Errorf("open chat text: %w", err)
+ }
+
+ defer func() {
+ closeErr := reader.Close()
+ if closeErr != nil {
+ err = errors.Join(err, fmt.Errorf("close export chat text: %w", closeErr))
+ }
+ }()
+
+ scanner := bufio.NewScanner(io.LimitReader(reader, whatsappImportBytes+1))
+ scanner.Buffer(make([]byte, 64*1024), buildLineBytes)
+ for scanner.Scan() {
+ line := strings.ReplaceAll(scanner.Text(), "\u200e", "")
+ match := whatsappImportStart.FindStringSubmatch(line)
+ if match == nil {
+ if len(messages) == 0 {
+ if line == "" {
+ continue
+ }
+
+ return chat, nil, errors.New("read export: text before the first dated message")
+ }
+
+ messages[len(messages)-1].Text += "\n" + line
+ continue
+ }
+
+ var date time.Time
+
+ date, err = time.ParseInLocation("02/01/2006, 15:04:05", match[1], location)
+ if err != nil {
+ return chat, nil, fmt.Errorf("parse export local date: %w", err)
+ }
+
+ messages = append(messages, WhatsAppMessage{
+ Sender: match[2],
+ Text: match[3],
+ Time: date,
+ Mine: owner != "" && match[2] == owner && match[2] != chat.Name,
+ })
+
+ if match[2] == chat.Name {
+ chat.Group = true
+ }
+ }
+
+ if scanner.Err() != nil {
+ return chat, nil, fmt.Errorf("read chat text: %w", scanner.Err())
+ }
+
+ if len(messages) == 0 {
+ return chat, nil, errors.New("read export: no dated messages")
+ }
+
+ slices.SortStableFunc(messages, func(a, b WhatsAppMessage) int {
+ return a.Time.Compare(b.Time)
+ })
+
+ return chat, messages, nil
+}
--- /dev/null
+package main
+
+import (
+ "archive/zip"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "go.mau.fi/whatsmeow/types"
+)
+
+func TestWhatsAppImportReadsMadeUpExport(t *testing.T) {
+ path := whatsappImportSample(t)
+ location := time.FixedZone("TEST", -3*60*60)
+ chat, messages, err := whatsappImportRead(path, "Sam Owner", location)
+ require.NoError(t, err)
+ require.Equal(t, "Book Club", chat.Name)
+ require.True(t, chat.Group)
+ require.Len(t, messages, 19)
+ require.Equal(t, "Book Club", messages[0].Sender)
+ require.False(t, messages[0].Mine)
+ require.Equal(t, "2025-05-20T12:35:39-03:00", messages[0].Time.Format(time.RFC3339))
+ require.Contains(t, messages[0].Text, "end-to-end encrypted")
+ require.Equal(t, "image omitted", messages[5].Text)
+ require.True(t, messages[6].Mine)
+ require.Equal(t, "~\u202fBen Carter", messages[7].Sender)
+ require.Equal(t, "Sure.\nThe room costs $40.00 more.\nI will send the booking tomorrow. 👍🏻", messages[7].Text)
+ require.Contains(t, messages[8].Text, "<This message was edited>")
+ require.Equal(t, "Booking.pdf • 3 pages document omitted", messages[9].Text)
+ require.Equal(t, "audio omitted", messages[10].Text)
+ require.True(t, messages[11].Mine)
+ require.Equal(t, "This message was deleted.", messages[11].Text)
+ require.Equal(t, "video omitted", messages[12].Text)
+ require.Equal(t, "Contact card omitted", messages[13].Text)
+ require.Equal(t, "sticker omitted", messages[14].Text)
+ require.Contains(t, messages[15].Text, "changed the group name")
+ require.Equal(t, "A line with [brackets] and: a colon in the middle", messages[16].Text)
+
+ for _, message := range messages {
+ require.NotContains(t, message.Text, "\r")
+ require.NotContains(t, message.Text, "\u200e")
+ require.NotContains(t, message.Sender, "\u200e")
+ }
+}
+
+func TestWhatsAppImportSplitsDaysAndMapsOnlyOwner(t *testing.T) {
+ path := whatsappImportSample(t)
+ names := map[string]WhatsAppChat{
+ "Book Club": {
+ ID: "example@g.us",
+ Name: "Book Club",
+ Group: true,
+ },
+ }
+
+ plan, err := whatsappImportPlan(t.Context(), []string{path}, nil, names, "Sam Owner", time.UTC)
+ require.NoError(t, err)
+ require.Len(t, plan.added, 5)
+ require.Equal(t, "2025-05-20T13:06:44Z", plan.added[0].Date)
+ require.Equal(t, "2025-05-21T09:23:00Z", plan.added[1].Date)
+ require.Equal(t, "2025-12-31T23:59:59Z", plan.added[3].Date)
+ require.Equal(t, "2026-01-01T00:00:01Z", plan.added[4].Date)
+ require.True(t, strings.HasPrefix(plan.added[0].Text, "[WhatsApp] Book Club, group example@g.us, Tuesday 2025-05-20 (imported)\n"))
+ require.Contains(t, plan.added[0].Text, "12:35 Book Club: Messages and calls")
+ require.Contains(t, plan.added[0].Text, "13:02 You: Nice.")
+ require.NotContains(t, plan.added[0].Text, "Sam Owner:")
+ require.Contains(t, plan.added[0].Text, "13:05 ~\u202fBen Carter: Sure.\nThe room costs")
+ require.NotContains(t, plan.added[0].Text, whatsappBatchEmptyReply)
+ require.Equal(t, "2025-05-20T12:35:39Z", plan.first.Format(time.RFC3339))
+ require.Equal(t, "2026-01-01T00:00:01Z", plan.last.Format(time.RFC3339))
+}
+
+func TestWhatsAppImportSkipsHeldDaysAndMessagesAfterLiveCutoff(t *testing.T) {
+ path := whatsappImportSample(t)
+ old := []DatedMessage{
+ {
+ Kind: "work",
+ Text: "[WhatsApp] Live, private chat other@lid, Sunday 2025-06-08\n10:00 Ada: Later",
+ Date: "2025-06-08T10:01:00Z",
+ },
+ {
+ Kind: "work",
+ Text: "[WhatsApp] Live, private chat other@lid, Tuesday 2025-06-03\n18:41 Ada: First\n18:42 Ada: Last",
+ Date: "2025-06-03T18:43:00Z",
+ },
+ {
+ Kind: "work",
+ Text: "[WhatsApp] Book Club, Tuesday 2025-05-20 (imported)\n12:35 Book Club: System",
+ Date: "2025-05-20T13:06:44Z",
+ },
+ }
+
+ plan, err := whatsappImportPlan(t.Context(), []string{path}, old, map[string]WhatsAppChat{}, "Sam Owner", time.UTC)
+ require.NoError(t, err)
+ require.Equal(t, "2025-06-03T18:41:00Z", plan.cutoff.Format(time.RFC3339))
+ require.Equal(t, 1, plan.skipped)
+ require.Len(t, plan.added, 2)
+ require.Equal(t, "2025-06-03T18:40:05Z", plan.added[1].Date)
+ require.NotContains(t, plan.added[1].Text, "18:41")
+ require.NotContains(t, plan.added[1].Text, ", group ")
+ require.Equal(t, len(old)+2, plan.messages)
+}
+
+func TestWhatsAppImportCutoffUsesLocalTimeAndExcludesBoundary(t *testing.T) {
+ path := whatsappImportZip(t, "Local", "[03/06/2025, 18:40:59] Ada: Before\n[03/06/2025, 18:41:00] Ada: Boundary\n[03/06/2025, 18:41:01] Ada: After\n")
+ old := []DatedMessage{{
+ Kind: "work",
+ Text: "[WhatsApp] Live, group live@g.us, Tuesday 2025-06-03\n18:41 Ada: First",
+ Date: "2025-06-04T01:00:00Z",
+ }}
+
+ plan, err := whatsappImportPlan(t.Context(), []string{path}, old, map[string]WhatsAppChat{}, "", time.FixedZone("TEST", -3*60*60))
+ require.NoError(t, err)
+ require.Equal(t, "2025-06-03T18:41:00-03:00", plan.cutoff.Format(time.RFC3339))
+ require.Len(t, plan.added, 1)
+ require.Contains(t, plan.added[0].Text, "Before")
+ require.NotContains(t, plan.added[0].Text, "Boundary")
+ require.NotContains(t, plan.added[0].Text, "After")
+}
+
+func TestWhatsAppImportKeepsSystemSenderEvenWithOwnerName(t *testing.T) {
+ path := whatsappImportZip(t, "Owner", "[01/01/2025, 00:00:00] Owner: System line\n")
+ _, messages, err := whatsappImportRead(path, "Owner", time.UTC)
+ require.NoError(t, err)
+ require.False(t, messages[0].Mine)
+}
+
+func TestWhatsAppImportRefusesOverlappingInputs(t *testing.T) {
+ path := whatsappImportSample(t)
+ _, err := whatsappImportPlan(t.Context(), []string{path, path}, nil, map[string]WhatsAppChat{}, "", time.UTC)
+ require.ErrorContains(t, err, "inputs overlap")
+}
+
+func TestWhatsAppImportSkipsAllDaysOnALaterStep(t *testing.T) {
+ path := whatsappImportSample(t)
+ first, err := whatsappImportPlan(t.Context(), []string{path}, nil, nil, "Sam Owner", time.UTC)
+ require.NoError(t, err)
+
+ second, err := whatsappImportPlan(t.Context(), []string{path}, first.added, nil, "Sam Owner", time.UTC)
+ require.NoError(t, err)
+ require.Empty(t, second.added)
+ require.Equal(t, 5, second.skipped)
+ require.True(t, second.cutoff.IsZero())
+}
+
+func TestWhatsAppImportMergesChatsByDate(t *testing.T) {
+ later := whatsappImportZip(t, "Later", "[02/01/2025, 08:00:00] Ada: Later\n")
+ earlier := whatsappImportZip(t, "Earlier", "[01/01/2025, 08:00:00] Bea: Earlier\n")
+ plan, err := whatsappImportPlan(t.Context(), []string{later, earlier}, nil, nil, "", time.UTC)
+ require.NoError(t, err)
+ require.Len(t, plan.added, 2)
+ require.Contains(t, plan.added[0].Text, "[WhatsApp] Earlier,")
+ require.Contains(t, plan.added[1].Text, "[WhatsApp] Later,")
+}
+
+func TestWhatsAppImportRefusesMissingOrDuplicateChatText(t *testing.T) {
+ testCases := []struct {
+ name string
+ members []string
+ want string
+ }{
+ {
+ name: "missing chat text is refused",
+ members: []string{"other.txt"},
+ want: "_chat.txt is missing",
+ },
+ {
+ name: "duplicate chat text is refused",
+ members: []string{"_chat.txt", "nested/_chat.txt"},
+ want: "more than one _chat.txt",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "Example.zip")
+ file, err := os.Create(path)
+ require.NoError(t, err)
+
+ archive := zip.NewWriter(file)
+ for _, member := range tc.members {
+ _, err = archive.Create(member)
+ require.NoError(t, err)
+ }
+
+ require.NoError(t, archive.Close())
+ require.NoError(t, file.Close())
+
+ _, _, err = whatsappImportRead(path, "", time.UTC)
+ require.ErrorContains(t, err, tc.want)
+ })
+ }
+}
+
+func TestWhatsAppImportRefusesMalformedExports(t *testing.T) {
+ testCases := []struct {
+ name string
+ text string
+ want string
+ }{
+ {
+ name: "text before the first date is refused",
+ text: "not a message\n",
+ want: "before the first",
+ },
+ {
+ name: "invalid local dates are refused",
+ text: "[31/02/2025, 10:00:00] Ada: Invalid\n",
+ want: "parse export local date",
+ },
+ {
+ name: "exports with no dated messages are refused",
+ text: "",
+ want: "no dated messages",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ _, _, err := whatsappImportRead(whatsappImportZip(t, "Example", tc.text), "", time.UTC)
+ require.ErrorContains(t, err, tc.want)
+ })
+ }
+}
+
+func TestWhatsAppImportPlanCommandDoesNotChangeAgentOrCallModel(t *testing.T) {
+ agent := newRebuildTestAgent(t)
+ dir := filepath.Dir(agent.dir)
+ t.Setenv("ONECHAT_DIR", dir)
+
+ // A plan does not need model settings or credentials.
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("not model settings"), 0o600))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "auth.json"), []byte("not credentials"), 0o600))
+
+ rebuildTestLog(t, agent.dir, []DatedMessage{{
+ Kind: "user",
+ Text: "Current",
+ Date: "2026-11-01T00:00:00Z",
+ }})
+
+ before := rebuildTestSnapshot(t, agent.dir)
+ var report strings.Builder
+ require.NoError(t, whatsappImport(t.Context(), []string{"-n", whatsappImportSample(t)}, &report))
+ require.Equal(t, before, rebuildTestSnapshot(t, agent.dir))
+
+ for _, want := range []string{"chats: 1", "Book Club", "batches: 5", "dates: ", "cutoff: none", "bytes: ", "messages: 6", "estimated calls: 8", "owner: unknown"} {
+ require.Contains(t, report.String(), want)
+ }
+
+ require.NotContains(t, report.String(), "end-to-end encrypted")
+ require.NotContains(t, report.String(), "Sam Owner")
+
+ lock, err := lockTokens(dir)
+ require.NoError(t, err)
+ require.ErrorIs(t, whatsappImport(t.Context(), []string{"-n", whatsappImportSample(t)}, &strings.Builder{}), errTokensInUse)
+ require.NoError(t, lock.Close())
+ require.NoError(t, os.WriteFile(filepath.Join(agent.dir, "work.json"), []byte(`{"running":["BlueBird"]}`), 0o600))
+
+ require.ErrorContains(t, whatsappImport(t.Context(), []string{"-n", whatsappImportSample(t)}, &strings.Builder{}), "unfinished work")
+}
+
+func TestWhatsAppImportDoesNotRebuildWhenAllDaysAreHeld(t *testing.T) {
+ agent := newRebuildTestAgent(t)
+ dir := filepath.Dir(agent.dir)
+ t.Setenv("ONECHAT_DIR", dir)
+ path := whatsappImportSample(t)
+ plan, err := whatsappImportPlan(t.Context(), []string{path}, nil, nil, "", time.UTC)
+ require.NoError(t, err)
+
+ rebuildTestLog(t, agent.dir, plan.added)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("not model settings"), 0o600))
+
+ before := rebuildTestSnapshot(t, agent.dir)
+ var report strings.Builder
+ require.NoError(t, whatsappImport(t.Context(), []string{path}, &report))
+ require.Contains(t, report.String(), "batches: 0")
+ require.Contains(t, report.String(), "estimated calls: 0")
+ require.Equal(t, before, rebuildTestSnapshot(t, agent.dir))
+}
+
+func TestWhatsAppImportReadsOnlyLocalSessionNames(t *testing.T) {
+ dir := t.TempDir()
+ whatsappTestSession(t, dir, true)
+ session := &WhatsApp{
+ dir: filepath.Join(dir, whatsappDirName),
+ }
+
+ err := session.openSession(t.Context())
+ require.NoError(t, err)
+
+ session.device.PushName = "Sam Owner"
+ session.device.Account.Details = []byte{}
+ require.NoError(t, session.device.Save(t.Context()))
+ require.NoError(t, session.device.Contacts.PutContactName(t.Context(), whatsappTestOwn, "", "Owner"))
+ require.NoError(t, session.device.Contacts.PutContactName(t.Context(), session.device.LID.ToNonAD(), "", "Owner LID"))
+
+ friend := types.NewJID("friend", types.HiddenUserServer)
+ require.NoError(t, session.device.Contacts.PutContactName(t.Context(), friend, "Ada", "Ada Example"))
+ require.NoError(t, session.device.Contacts.PutContactName(t.Context(), types.NewJID("other", types.HiddenUserServer), "Ada", ""))
+ require.NoError(t, session.container.Close())
+
+ names, owner, err := whatsappImportNames(t.Context(), dir)
+ require.NoError(t, err)
+ require.Equal(t, "Sam Owner", owner)
+ require.Equal(t, "friend@lid", names["Ada Example"].ID)
+ require.Empty(t, names["Ada"].ID)
+ require.NotContains(t, names, "Owner")
+ require.NotContains(t, names, "Owner LID")
+}
+
+func whatsappImportSample(t *testing.T) string {
+ t.Helper()
+
+ data, err := os.ReadFile(filepath.Join("testdata", "whatsapp-export.zip"))
+ require.NoError(t, err)
+
+ path := filepath.Join(t.TempDir(), "WhatsApp Chat - Book Club.zip")
+ require.NoError(t, os.WriteFile(path, data, 0o600))
+
+ return path
+}
+
+func whatsappImportZip(t *testing.T, name, text string) string {
+ t.Helper()
+
+ path := filepath.Join(t.TempDir(), "WhatsApp Chat - "+name+".zip")
+ file, err := os.Create(path)
+ require.NoError(t, err)
+
+ archive := zip.NewWriter(file)
+ writer, err := archive.Create("_chat.txt")
+ require.NoError(t, err)
+
+ _, err = writer.Write([]byte(text))
+ require.NoError(t, err)
+ require.NoError(t, archive.Close())
+ require.NoError(t, file.Close())
+
+ return path
+}