From: Anderson A. Giacomolli Date: Mon, 9 Nov 2026 20:29:48 +0000 (-0300) Subject: Import WhatsApp exports before live messages X-Git-Url: https://git.giacomolli.uk/onechat/commitdiff_plain/900ec0e7dad7a913190fcef9016e53495de9bdf8?ds=sidebyside;hp=a606686934b4028c29dca8e9dae6b2e188cee9c5 Import WhatsApp exports before live messages "onechat import [-n] ZIP..." reads the chat exports of the phone, makes one marked WhatsApp batch for each chat and day, and rebuilds the memory with them in date order. It takes only messages from before the first live batch, maps the owner's profile name to "You", and skips the chat days that an earlier import added. With -n it prints the plan and the estimated calls and makes no model call. The names, the chat IDs, and the owner come from the WhatsApp session through the library's store, so the import runs only while the daemon is stopped and needs no network. Issue: b0b8bf9 --- diff --git a/README.rst b/README.rst index 7135226..9893fc4 100644 --- a/README.rst +++ b/README.rst @@ -370,6 +370,45 @@ the same session, all clients get a notice at once. When the user 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 *********** diff --git a/main.go b/main.go index 04b0677..f4f09c4 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,8 @@ const usage = `Usage: onechat [COMMAND] 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 @@ -52,6 +54,8 @@ func run() error { 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 { diff --git a/testdata/whatsapp-export.zip b/testdata/whatsapp-export.zip new file mode 100644 index 0000000..5b0d302 Binary files /dev/null and b/testdata/whatsapp-export.zip differ diff --git a/whatsapp_import.go b/whatsapp_import.go new file mode 100644 index 0000000..062d3ca --- /dev/null +++ b/whatsapp_import.go @@ -0,0 +1,568 @@ +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 +} diff --git a/whatsapp_import_test.go b/whatsapp_import_test.go new file mode 100644 index 0000000..5f7d21c --- /dev/null +++ b/whatsapp_import_test.go @@ -0,0 +1,343 @@ +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, "") + 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 +}