19 "go.mau.fi/whatsmeow/store/sqlstore"
20 waLog "go.mau.fi/whatsmeow/util/log"
24 // whatsappImportMark marks history in the first line of a batch.
25 whatsappImportMark = " (imported)"
27 // whatsappImportBytes limits one export's uncompressed chat text.
28 whatsappImportBytes = 64 << 20
32 whatsappImportStart = regexp.MustCompile(`^\[(\d{2}/\d{2}/\d{4}, \d{2}:\d{2}:\d{2})\] ([^\r\n]+?): ?(.*)$`)
33 whatsappImportHeader = regexp.MustCompile(`^\[WhatsApp\] (.*), (?:group|private chat) ([^ ,]+), [A-Za-z]+ (\d{4}-\d{2}-\d{2})$`)
34 whatsappImportUnknown = regexp.MustCompile(`^\[WhatsApp\] (.*), [A-Za-z]+ (\d{4}-\d{2}-\d{2})$`)
37 // whatsappImport runs the offline import command. It holds the same token lock
38 // as the daemon and probe, even for a plan. The plan reads no credentials for a
39 // model and makes no network call. Only a completed rebuild replaces the agent
40 // directory, and unfinished subagents prevent either mode.
41 func whatsappImport(ctx context.Context, args []string, out io.Writer) (err error) {
42 planOnly := len(args) > 0 && args[0] == "-n"
48 return errors.New("usage: onechat import [-n] ZIP [ZIP...]")
51 for _, arg := range args {
52 if strings.HasPrefix(arg, "-") {
53 return errors.New("usage: onechat import [-n] ZIP [ZIP...]")
59 return fmt.Errorf("find import data directory: %w", err)
62 tokens, err := lockTokens(dir)
64 return fmt.Errorf("lock import tokens: %w", err)
68 closeErr := tokens.Close()
70 err = errors.Join(err, fmt.Errorf("close import token lock: %w", closeErr))
74 root := filepath.Join(dir, "agent")
75 err = rebuildIdle(root)
77 return fmt.Errorf("check import work: %w", err)
80 old, err := rebuildReadLog(ctx, root)
82 return fmt.Errorf("read import log: %w", err)
85 names, owner, err := whatsappImportNames(ctx, dir)
87 return fmt.Errorf("read import names: %w", err)
90 plan, err := whatsappImportPlan(ctx, args, old, names, owner, time.Local)
92 return fmt.Errorf("plan import: %w", err)
97 return fmt.Errorf("write import plan: %w", err)
100 if planOnly || len(plan.added) == 0 {
104 // An existing agent is required so that a failed import never creates
105 // or changes the original agent's configuration.
106 _, err = os.Stat(filepath.Join(root, "config.json"))
108 return fmt.Errorf("read agent config: %w", err)
111 agent, err := openAgent(root)
113 return fmt.Errorf("open import agent: %w", err)
116 config, err := loadConfig(dir)
118 return fmt.Errorf("load import configuration: %w", err)
121 address, auth, err := modelEndpoint(config, dir)
123 return fmt.Errorf("select import endpoint: %w", err)
126 usage, err := os.CreateTemp(dir, ".import-usage-*.jsonl")
128 return fmt.Errorf("create import usage: %w", err)
131 usagePath := usage.Name()
133 closeErr := os.Remove(usagePath)
135 err = errors.Join(err, fmt.Errorf("remove import usage: %w", closeErr))
141 return fmt.Errorf("close import usage: %w", err)
144 client := newModelClient(address, auth, agent.CacheKey(), usagePath)
145 backup, err := rebuild(ctx, agent, plan.added, client, newCompactor(client, agent.Prompt, config.Compactor), out)
147 return fmt.Errorf("rebuild imported memory: %w", err)
150 _, err = fmt.Fprintf(out, "import complete: backup %s\n", backup)
152 return fmt.Errorf("write import result: %w", err)
158 // WhatsAppImportPlan holds the new chat-day batches and the size of the merged
159 // log. Its estimate uses the measured cost of 1.3 calls per message, not a
160 // promise about a particular export or compaction retry.
161 type WhatsAppImportPlan struct {
173 // Write prints the plan without chat text or the owner's profile name.
174 func (p WhatsAppImportPlan) Write(out io.Writer) error {
175 cutoff := "none (no live WhatsApp batch)"
176 if !p.cutoff.IsZero() {
177 cutoff = p.cutoff.Format(time.RFC3339)
181 if !p.first.IsZero() {
182 dateRange = p.first.Format(time.RFC3339) + " to " + p.last.Format(time.RFC3339)
185 owner := "known (mapped to You)"
187 owner = "unknown (sender names kept as written)"
190 _, err := fmt.Fprintf(out, "chats: %d\n", len(p.chats))
192 return fmt.Errorf("write import plan: %w", err)
195 for _, chat := range p.chats {
196 _, err = fmt.Fprintf(out, " %s\n", chat)
198 return fmt.Errorf("write import plan: %w", err)
202 estimate := 1.3 * float64(p.messages)
203 if len(p.added) == 0 {
208 fmt.Sprintf("batches: %d (%d chat-days skipped)", len(p.added), p.skipped),
209 "dates: " + dateRange,
212 fmt.Sprintf("bytes: %d (new batch text)", p.bytes),
213 fmt.Sprintf("messages: %d (merged log)", p.messages),
214 fmt.Sprintf("estimated calls: %.0f (about 1.3 per message, limit %d)", estimate, buildCallLimit),
217 _, err = io.WriteString(out, strings.Join(lines, "\n")+"\n")
219 return fmt.Errorf("write import plan: %w", err)
225 // whatsappImportNames reads only local session data. Group names that the
226 // library keeps only in memory can also come from logged or pending batches.
227 // Ambiguous names have no ID. A missing session keeps sender names unchanged.
228 func whatsappImportNames(ctx context.Context, dir string) (names map[string]WhatsAppChat, owner string, err error) {
229 names = make(map[string]WhatsAppChat)
230 path := filepath.Join(dir, whatsappDirName, whatsappSessionFileName)
231 _, err = os.Stat(path)
232 if errors.Is(err, os.ErrNotExist) {
233 return names, "", nil
237 return nil, "", fmt.Errorf("read import session: %w", err)
240 address, err := whatsappSessionAddress(path)
242 return nil, "", fmt.Errorf("find import session address: %w", err)
245 container, err := sqlstore.New(ctx, "sqlite", address, waLog.Noop)
247 return nil, "", fmt.Errorf("open import session: %w", err)
251 closeErr := container.Close()
253 err = errors.Join(err, fmt.Errorf("close import session: %w", closeErr))
257 device, err := container.GetFirstDevice(ctx)
259 return nil, "", fmt.Errorf("read import device: %w", err)
262 if device.ID == nil {
263 return names, "", nil
266 owner = device.PushName
267 own := whatsappOwnIDs(device.ID.User, device.LID.User)
268 contacts, err := device.Contacts.GetAllContacts(ctx)
270 return nil, "", fmt.Errorf("read import contacts: %w", err)
273 for jid, contact := range contacts {
274 if slices.Contains(own, jid.User) {
278 for _, name := range []string{contact.FirstName, contact.FullName, contact.PushName, contact.BusinessName} {
280 whatsappImportName(names, WhatsAppChat{
281 ID: jid.ToNonAD().String(),
283 Group: jid.Server == "g.us",
289 data, err := os.ReadFile(filepath.Join(dir, whatsappDirName, whatsappStateFileName))
290 if errors.Is(err, os.ErrNotExist) {
291 return names, owner, nil
295 return nil, "", fmt.Errorf("read pending import names: %w", err)
298 var state WhatsAppStateFile
299 err = json.Unmarshal(data, &state)
301 return nil, "", fmt.Errorf("decode pending import names: %w", err)
304 for _, batch := range state.Batches {
305 whatsappImportName(names, batch.Chat)
308 return names, owner, nil
311 // whatsappImportName adds an exact match unless distinct IDs share its name.
312 func whatsappImportName(names map[string]WhatsAppChat, chat WhatsAppChat) {
313 old, found := names[chat.Name]
314 if found && old.ID != chat.ID {
318 names[chat.Name] = chat
321 // whatsappImportPlan filters exports at the first message of the earliest live
322 // batch and skips chat-days already imported. Each remaining chat-day becomes a
323 // work message dated at its last exported message. Overlapping chat-days in the
324 // inputs are refused, not silently combined or duplicated.
325 func whatsappImportPlan(ctx context.Context, paths []string, old []DatedMessage, names map[string]WhatsAppChat, owner string, location *time.Location) (WhatsAppImportPlan, error) {
326 plan := WhatsAppImportPlan{ownerKnown: owner != ""}
327 names = maps.Clone(names)
329 names = make(map[string]WhatsAppChat)
332 imported := make(map[string]bool)
333 for _, entry := range old {
334 if entry.Kind != "work" || !strings.HasPrefix(entry.Text, "[WhatsApp] ") {
338 first, _, _ := strings.Cut(entry.Text, "\n")
339 marked := strings.HasSuffix(first, whatsappImportMark)
340 first = strings.TrimSuffix(first, whatsappImportMark)
341 match := whatsappImportHeader.FindStringSubmatch(first)
344 name, day = match[1], match[3]
345 whatsappImportName(names, WhatsAppChat{
348 Group: strings.Contains(first, ", group "+match[2]+","),
351 match = whatsappImportUnknown.FindStringSubmatch(first)
353 name, day = match[1], match[2]
358 return plan, errors.New("read WhatsApp history: invalid batch header")
362 imported[name+"\n"+day] = true
366 _, body, found := strings.Cut(entry.Text, "\n")
367 if !found || len(body) < len("15:04 ") {
368 return plan, errors.New("read WhatsApp history: missing first message time")
371 date, err := time.ParseInLocation("2006-01-02 15:04", day+" "+body[:5], location)
373 return plan, fmt.Errorf("parse first live message time: %w", err)
376 if plan.cutoff.IsZero() || date.Before(plan.cutoff) {
381 seen := make(map[string]bool)
382 for _, path := range paths {
383 if ctx.Err() != nil {
384 return plan, ctx.Err()
387 chat, messages, err := whatsappImportRead(path, owner, location)
389 return plan, fmt.Errorf("read import export: %w", err)
392 known, found := names[chat.Name]
397 if !slices.Contains(plan.chats, chat.Name) {
398 plan.chats = append(plan.chats, chat.Name)
401 days := make(map[string][]WhatsAppMessage)
402 for _, message := range messages {
403 if !plan.cutoff.IsZero() && !message.Time.Before(plan.cutoff) {
407 day := message.Time.In(location).Format(time.DateOnly)
408 days[day] = append(days[day], message)
411 for _, day := range slices.Sorted(maps.Keys(days)) {
412 key := chat.Name + "\n" + day
419 return plan, errors.New("import inputs overlap: give only one export for each chat and day")
423 batch := WhatsAppBatch{
428 text := batch.Text(location)
429 first, body, _ := strings.Cut(text, "\n")
431 first = "[WhatsApp] " + chat.Name + ", " + batch.Messages[0].Time.In(location).Format(whatsappBatchDayLayout)
434 text = first + whatsappImportMark + "\n" + body
435 plan.added = append(plan.added, DatedMessage{
438 Date: batch.Messages[len(batch.Messages)-1].Time.Format(time.RFC3339Nano),
441 plan.bytes += len(text)
442 firstDate, lastDate := batch.Messages[0].Time, batch.Messages[len(batch.Messages)-1].Time
443 if plan.first.IsZero() || firstDate.Before(plan.first) {
444 plan.first = firstDate
447 if plan.last.IsZero() || lastDate.After(plan.last) {
454 plan.added, err = rebuildMerge(nil, plan.added)
456 return plan, fmt.Errorf("order import batches: %w", err)
459 plan.messages = len(old) + len(plan.added)
460 slices.Sort(plan.chats)
465 // whatsappImportRead reads the single _chat.txt in an export without extracting
466 // archive paths. Non-start lines continue the preceding message, and all
467 // invisible left-to-right marks are removed. Dates use location. Media and
468 // system messages remain text, including the written sender.
469 func whatsappImportRead(path, owner string, location *time.Location) (chat WhatsAppChat, messages []WhatsAppMessage, err error) {
470 chat.Name = strings.TrimPrefix(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)), "WhatsApp Chat - ")
471 if chat.Name == "" || strings.ContainsAny(chat.Name, "\r\n") {
472 return chat, nil, errors.New("read export: chat name must be one nonempty line")
475 archive, err := zip.OpenReader(path)
477 return chat, nil, fmt.Errorf("open export: %w", err)
481 closeErr := archive.Close()
483 err = errors.Join(err, fmt.Errorf("close export: %w", closeErr))
488 for _, entry := range archive.File {
489 if filepath.Base(entry.Name) == "_chat.txt" && !entry.FileInfo().IsDir() {
491 return chat, nil, errors.New("read export: more than one _chat.txt")
499 return chat, nil, errors.New("read export: _chat.txt is missing")
502 if file.UncompressedSize64 > whatsappImportBytes {
503 return chat, nil, errors.New("read export: chat text exceeds 64 MiB")
506 reader, err := file.Open()
508 return chat, nil, fmt.Errorf("open chat text: %w", err)
512 closeErr := reader.Close()
514 err = errors.Join(err, fmt.Errorf("close export chat text: %w", closeErr))
518 scanner := bufio.NewScanner(io.LimitReader(reader, whatsappImportBytes+1))
519 scanner.Buffer(make([]byte, 64*1024), buildLineBytes)
521 line := strings.ReplaceAll(scanner.Text(), "\u200e", "")
522 match := whatsappImportStart.FindStringSubmatch(line)
524 if len(messages) == 0 {
529 return chat, nil, errors.New("read export: text before the first dated message")
532 messages[len(messages)-1].Text += "\n" + line
539 date, err = time.ParseInLocation("02/01/2006, 15:04:05", match[1], location)
541 return chat, nil, fmt.Errorf("parse export local date: %w", err)
544 messages = append(messages, WhatsAppMessage{
548 Mine: owner != "" && match[2] == owner && match[2] != chat.Name,
551 if match[2] == chat.Name {
556 if scanner.Err() != nil {
557 return chat, nil, fmt.Errorf("read chat text: %w", scanner.Err())
560 if len(messages) == 0 {
561 return chat, nil, errors.New("read export: no dated messages")
564 slices.SortStableFunc(messages, func(a, b WhatsAppMessage) int {
565 return a.Time.Compare(b.Time)
568 return chat, messages, nil