]> git.giacomolli.uk - onechat/blob - whatsapp_import_test.go
Send stored images to WhatsApp chats
[onechat] / whatsapp_import_test.go
1 package main
2
3 import (
4 "archive/zip"
5 "encoding/json"
6 "net/http"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/require"
14 "go.mau.fi/whatsmeow/types"
15 )
16
17 func TestWhatsAppImportReadsMadeUpExport(t *testing.T) {
18 path := whatsappImportSample(t)
19 location := time.FixedZone("TEST", -3*60*60)
20 chat, messages, err := whatsappImportRead(path, "Sam Owner", location)
21 require.NoError(t, err)
22 require.Equal(t, "Book Club", chat.Name)
23 require.True(t, chat.Group)
24 require.Len(t, messages, 19)
25 require.Equal(t, "Book Club", messages[0].Sender)
26 require.False(t, messages[0].Mine)
27 require.Equal(t, "2025-05-20T12:35:39-03:00", messages[0].Time.Format(time.RFC3339))
28 require.Contains(t, messages[0].Text, "end-to-end encrypted")
29 require.Equal(t, "image omitted", messages[5].Text)
30 require.True(t, messages[6].Mine)
31 require.Equal(t, "~\u202fBen Carter", messages[7].Sender)
32 require.Equal(t, "Sure.\nThe room costs $40.00 more.\nI will send the booking tomorrow. 👍🏻", messages[7].Text)
33 require.Contains(t, messages[8].Text, "<This message was edited>")
34 require.Equal(t, "Booking.pdf • 3 pages document omitted", messages[9].Text)
35 require.Equal(t, "audio omitted", messages[10].Text)
36 require.True(t, messages[11].Mine)
37 require.Equal(t, "This message was deleted.", messages[11].Text)
38 require.Equal(t, "video omitted", messages[12].Text)
39 require.Equal(t, "Contact card omitted", messages[13].Text)
40 require.Equal(t, "sticker omitted", messages[14].Text)
41 require.Contains(t, messages[15].Text, "changed the group name")
42 require.Equal(t, "A line with [brackets] and: a colon in the middle", messages[16].Text)
43
44 for _, message := range messages {
45 require.NotContains(t, message.Text, "\r")
46 require.NotContains(t, message.Text, "\u200e")
47 require.NotContains(t, message.Sender, "\u200e")
48 }
49 }
50
51 func TestWhatsAppImportSplitsDaysAndMapsOnlyOwner(t *testing.T) {
52 path := whatsappImportSample(t)
53 names := map[string]WhatsAppChat{
54 "Book Club": {
55 ID: "example@g.us",
56 Name: "Book Club",
57 Group: true,
58 },
59 }
60
61 plan, err := whatsappImportPlan(t.Context(), []string{path}, nil, nil, names, "Sam Owner", time.UTC)
62 require.NoError(t, err)
63 require.Len(t, plan.added, 5)
64 require.Equal(t, "2025-05-20T13:06:44Z", plan.added[0].Date)
65 require.Equal(t, "2025-05-21T09:23:00Z", plan.added[1].Date)
66 require.Equal(t, "2025-12-31T23:59:59Z", plan.added[3].Date)
67 require.Equal(t, "2026-01-01T00:00:01Z", plan.added[4].Date)
68 require.True(t, strings.HasPrefix(plan.added[0].Text, "[WhatsApp] Book Club, group example@g.us, Tuesday 2025-05-20 (imported)\n"))
69 require.Contains(t, plan.added[0].Text, "12:35 Book Club: Messages and calls")
70 require.Contains(t, plan.added[0].Text, "13:02 You: Nice.")
71 require.NotContains(t, plan.added[0].Text, "Sam Owner:")
72 require.Contains(t, plan.added[0].Text, "13:05 ~\u202fBen Carter: Sure.\nThe room costs")
73 require.NotContains(t, plan.added[0].Text, whatsappBatchEmptyReply)
74 require.Equal(t, "2025-05-20T12:35:39Z", plan.first.Format(time.RFC3339))
75 require.Equal(t, "2026-01-01T00:00:01Z", plan.last.Format(time.RFC3339))
76 }
77
78 func TestWhatsAppImportSkipsHeldDaysAndMessagesAfterLiveCutoff(t *testing.T) {
79 path := whatsappImportSample(t)
80 old := []DatedMessage{
81 {
82 Kind: "work",
83 Text: "[WhatsApp] Book Club, private chat other@lid, Sunday 2025-06-08\n10:00 Ada: Later",
84 Date: "2025-06-08T10:01:00Z",
85 },
86 {
87 Kind: "work",
88 Text: "[WhatsApp] Book Club, private chat other@lid, Tuesday 2025-06-03\n18:41 Ada: First\n18:42 Ada: Last",
89 Date: "2025-06-03T18:43:00Z",
90 },
91 {
92 Kind: "work",
93 Text: "[WhatsApp] Book Club, Tuesday 2025-05-20 (imported)\n12:35 Book Club: System",
94 Date: "2025-05-20T13:06:44Z",
95 },
96 }
97
98 plan, err := whatsappImportPlan(t.Context(), []string{path}, old, nil, map[string]WhatsAppChat{}, "Sam Owner", time.UTC)
99 require.NoError(t, err)
100 require.Equal(t, "2025-06-03T18:41:00Z", plan.cutoff.Format(time.RFC3339))
101 require.Equal(t, 1, plan.skipped)
102 require.Len(t, plan.added, 2)
103 require.Equal(t, "2025-06-03T18:40:05Z", plan.added[1].Date)
104 require.NotContains(t, plan.added[1].Text, "18:41")
105 require.NotContains(t, plan.added[1].Text, ", group ")
106 require.Equal(t, len(old)+2, plan.messages)
107 }
108
109 func TestWhatsAppImportCutoffUsesLocalTimeAndExcludesBoundary(t *testing.T) {
110 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")
111 old := []DatedMessage{{
112 Kind: "work",
113 Text: "[WhatsApp] Local, group live@g.us, Tuesday 2025-06-03\n18:41 Ada: First",
114 Date: "2025-06-04T01:00:00Z",
115 }}
116
117 plan, err := whatsappImportPlan(t.Context(), []string{path}, old, nil, map[string]WhatsAppChat{}, "", time.FixedZone("TEST", -3*60*60))
118 require.NoError(t, err)
119 require.Equal(t, "2025-06-03T18:41:00-03:00", plan.cutoff.Format(time.RFC3339))
120 require.Len(t, plan.added, 1)
121 require.Contains(t, plan.added[0].Text, "Before")
122 require.NotContains(t, plan.added[0].Text, "Boundary")
123 require.NotContains(t, plan.added[0].Text, "After")
124 }
125
126 func TestWhatsAppImportKeepsSystemSenderEvenWithOwnerName(t *testing.T) {
127 path := whatsappImportZip(t, "Owner", "[01/01/2025, 00:00:00] Owner: System line\n")
128 _, messages, err := whatsappImportRead(path, "Owner", time.UTC)
129 require.NoError(t, err)
130 require.False(t, messages[0].Mine)
131 }
132
133 func TestWhatsAppImportRefusesOverlappingInputs(t *testing.T) {
134 path := whatsappImportSample(t)
135 _, err := whatsappImportPlan(t.Context(), []string{path, path}, nil, nil, map[string]WhatsAppChat{}, "", time.UTC)
136 require.ErrorContains(t, err, "inputs overlap")
137 }
138
139 func TestWhatsAppImportSkipsAllDaysOnALaterStep(t *testing.T) {
140 path := whatsappImportSample(t)
141 first, err := whatsappImportPlan(t.Context(), []string{path}, nil, nil, nil, "Sam Owner", time.UTC)
142 require.NoError(t, err)
143
144 second, err := whatsappImportPlan(t.Context(), []string{path}, first.added, nil, nil, "Sam Owner", time.UTC)
145 require.NoError(t, err)
146 require.Empty(t, second.added)
147 require.Equal(t, 5, second.skipped)
148 require.True(t, second.cutoff.IsZero())
149 }
150
151 func TestWhatsAppImportMergesChatsByDate(t *testing.T) {
152 later := whatsappImportZip(t, "Later", "[02/01/2025, 08:00:00] Ada: Later\n")
153 earlier := whatsappImportZip(t, "Earlier", "[01/01/2025, 08:00:00] Bea: Earlier\n")
154 plan, err := whatsappImportPlan(t.Context(), []string{later, earlier}, nil, nil, nil, "", time.UTC)
155 require.NoError(t, err)
156 require.Len(t, plan.added, 2)
157 require.Contains(t, plan.added[0].Text, "[WhatsApp] Earlier,")
158 require.Contains(t, plan.added[1].Text, "[WhatsApp] Later,")
159 }
160
161 func TestWhatsAppImportRefusesMissingOrDuplicateChatText(t *testing.T) {
162 testCases := []struct {
163 name string
164 members []string
165 want string
166 }{
167 {
168 name: "missing chat text is refused",
169 members: []string{"other.txt"},
170 want: "_chat.txt is missing",
171 },
172 {
173 name: "duplicate chat text is refused",
174 members: []string{"_chat.txt", "nested/_chat.txt"},
175 want: "more than one _chat.txt",
176 },
177 }
178
179 for _, tc := range testCases {
180 t.Run(tc.name, func(t *testing.T) {
181 path := filepath.Join(t.TempDir(), "Example.zip")
182 file, err := os.Create(path)
183 require.NoError(t, err)
184
185 archive := zip.NewWriter(file)
186 for _, member := range tc.members {
187 _, err = archive.Create(member)
188 require.NoError(t, err)
189 }
190
191 require.NoError(t, archive.Close())
192 require.NoError(t, file.Close())
193
194 _, _, err = whatsappImportRead(path, "", time.UTC)
195 require.ErrorContains(t, err, tc.want)
196 })
197 }
198 }
199
200 func TestWhatsAppImportRefusesMalformedExports(t *testing.T) {
201 testCases := []struct {
202 name string
203 text string
204 want string
205 }{
206 {
207 name: "text before the first date is refused",
208 text: "not a message\n",
209 want: "before the first",
210 },
211 {
212 name: "invalid local dates are refused",
213 text: "[31/02/2025, 10:00:00] Ada: Invalid\n",
214 want: "parse export local date",
215 },
216 {
217 name: "exports with no dated messages are refused",
218 text: "",
219 want: "no dated messages",
220 },
221 }
222
223 for _, tc := range testCases {
224 t.Run(tc.name, func(t *testing.T) {
225 _, _, err := whatsappImportRead(whatsappImportZip(t, "Example", tc.text), "", time.UTC)
226 require.ErrorContains(t, err, tc.want)
227 })
228 }
229 }
230
231 func TestWhatsAppImportPlanCommandDoesNotChangeAgentOrCallModel(t *testing.T) {
232 agent := newRebuildTestAgent(t)
233 dir := filepath.Dir(agent.dir)
234 t.Setenv("ONECHAT_DIR", dir)
235
236 // A plan does not need model settings or credentials.
237 require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("not model settings"), 0o600))
238 require.NoError(t, os.WriteFile(filepath.Join(dir, "auth.json"), []byte("not credentials"), 0o600))
239
240 rebuildTestLog(t, agent.dir, []DatedMessage{{
241 Kind: "user",
242 Text: "Current",
243 Date: "2026-11-01T00:00:00Z",
244 }})
245
246 before := rebuildTestSnapshot(t, agent.dir)
247
248 var report strings.Builder
249
250 require.NoError(t, whatsappImport(t.Context(), []string{"-n", whatsappImportSample(t)}, &report))
251 require.Equal(t, before, rebuildTestSnapshot(t, agent.dir))
252
253 for _, want := range []string{"chats: 1", "Book Club", "batches: 5", "dates: ", "cutoff: none", "bytes: ", "messages: 6", "estimated calls: 8 (about 1.3 per message, limit 1500)", "owner: unknown"} {
254 require.Contains(t, report.String(), want)
255 }
256
257 require.NotContains(t, report.String(), "end-to-end encrypted")
258 require.NotContains(t, report.String(), "Sam Owner")
259
260 lock, err := lockTokens(dir)
261 require.NoError(t, err)
262 require.ErrorIs(t, whatsappImport(t.Context(), []string{"-n", whatsappImportSample(t)}, &strings.Builder{}), errTokensInUse)
263 require.NoError(t, lock.Close())
264 require.NoError(t, os.WriteFile(filepath.Join(agent.dir, "work.json"), []byte(`{"running":["BlueBird"]}`), 0o600))
265
266 require.ErrorContains(t, whatsappImport(t.Context(), []string{"-n", whatsappImportSample(t)}, &strings.Builder{}), "unfinished work")
267 }
268
269 func TestWhatsAppImportDoesNotRebuildWhenAllDaysAreHeld(t *testing.T) {
270 agent := newRebuildTestAgent(t)
271 dir := filepath.Dir(agent.dir)
272 t.Setenv("ONECHAT_DIR", dir)
273
274 path := whatsappImportSample(t)
275 plan, err := whatsappImportPlan(t.Context(), []string{path}, nil, nil, nil, "", time.UTC)
276 require.NoError(t, err)
277
278 rebuildTestLog(t, agent.dir, plan.added)
279 require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("not model settings"), 0o600))
280
281 before := rebuildTestSnapshot(t, agent.dir)
282
283 var report strings.Builder
284
285 require.NoError(t, whatsappImport(t.Context(), []string{path}, &report))
286 require.Contains(t, report.String(), "batches: 0")
287 require.Contains(t, report.String(), "estimated calls: 0")
288 require.Equal(t, before, rebuildTestSnapshot(t, agent.dir))
289 }
290
291 func TestWhatsAppImportReadsOnlyLocalSessionNames(t *testing.T) {
292 dir := t.TempDir()
293 whatsappTestSession(t, dir, true)
294
295 session := &WhatsApp{
296 dir: filepath.Join(dir, whatsappDirName),
297 }
298
299 err := session.openSession(t.Context())
300 require.NoError(t, err)
301
302 session.device.PushName = "Sam Owner"
303 session.device.Account.Details = []byte{}
304 require.NoError(t, session.device.Save(t.Context()))
305 require.NoError(t, session.device.Contacts.PutContactName(t.Context(), whatsappTestOwn, "", "Owner"))
306 require.NoError(t, session.device.Contacts.PutContactName(t.Context(), session.device.LID.ToNonAD(), "", "Owner LID"))
307
308 friend := types.NewJID("friend", types.HiddenUserServer)
309 require.NoError(t, session.device.Contacts.PutContactName(t.Context(), friend, "Ada", "Ada Example"))
310 require.NoError(t, session.device.Contacts.PutContactName(t.Context(), types.NewJID("other", types.HiddenUserServer), "Ada", ""))
311 require.NoError(t, session.container.Close())
312
313 names, owner, err := whatsappImportNames(t.Context(), dir)
314 require.NoError(t, err)
315 require.Equal(t, "Sam Owner", owner)
316 require.Equal(t, "friend@lid", names["Ada Example"].ID)
317 require.Empty(t, names["Ada"].ID)
318 require.NotContains(t, names, "Owner")
319 require.NotContains(t, names, "Owner LID")
320 }
321
322 func TestWhatsAppImportCutoffUsesEachChatAndBothLogs(t *testing.T) {
323 path := whatsappImportZip(t, "Plans", "[03/01/2025, 09:59:59] Ada: Before\n[03/01/2025, 10:00:00] Ada: Boundary\n[03/01/2025, 10:01:00] Ada: After\n")
324 target := []DatedMessage{{
325 Kind: "work",
326 Text: "[WhatsApp] Plans, group example@g.us, Friday 2025-01-03\n10:00 Ada: Target first\n10:30 Ada: Target last",
327 Date: "2025-01-03T10:30:00Z",
328 }}
329
330 root := []DatedMessage{
331 {
332 Kind: "work",
333 Text: "[WhatsApp] Other, group other@g.us, Wednesday 2025-01-01\n08:00 Bea: Other chat",
334 Date: "2025-01-01T08:00:00Z",
335 },
336 {
337 Kind: "work",
338 Text: "[WhatsApp] Plans, group example@g.us, Saturday 2025-01-04\n10:00 Ada: Later batch",
339 Date: "2025-01-04T10:00:00Z",
340 },
341 }
342
343 testCases := []struct {
344 name string
345 target []DatedMessage
346 root []DatedMessage
347 }{
348 {
349 name: "target holds the first live batch",
350 target: target,
351 root: root,
352 },
353 {
354 name: "root holds the first live batch",
355 target: root,
356 root: target,
357 },
358 }
359
360 for _, tc := range testCases {
361 t.Run(tc.name, func(t *testing.T) {
362 plan, err := whatsappImportPlan(t.Context(), []string{path}, tc.target, tc.root, nil, "", time.UTC)
363 require.NoError(t, err)
364 require.Equal(t, "2025-01-03T10:00:00Z", plan.cutoff.Format(time.RFC3339))
365 require.Len(t, plan.added, 1)
366 require.Contains(t, plan.added[0].Text, "Before")
367 require.NotContains(t, plan.added[0].Text, "Boundary")
368 require.NotContains(t, plan.added[0].Text, "After")
369 })
370 }
371 }
372
373 func TestWhatsAppImportSelectsOnlyAnExistingDirectChild(t *testing.T) {
374 root := newRebuildTestAgent(t)
375 dir := filepath.Dir(root.dir)
376 t.Setenv("ONECHAT_DIR", dir)
377 daemonChildConfig(t, dir, "House")
378
379 before := rebuildTestSnapshot(t, root.dir)
380 for _, name := range []string{"Missing", "../House", root.config.Name} {
381 err := whatsappImport(t.Context(), []string{"-n", "--agent", name, whatsappImportSample(t)}, &strings.Builder{})
382 require.ErrorContains(t, err, "does not exist")
383 require.Equal(t, before, rebuildTestSnapshot(t, root.dir))
384 }
385 }
386
387 func TestWhatsAppImportParsesGNUFlags(t *testing.T) {
388 testCases := []struct {
389 name string
390 args []string
391 want WhatsAppImportOptions
392 }{
393 {
394 name: "default target remains root",
395 args: []string{"One.zip", "Two.zip"},
396 want: WhatsAppImportOptions{paths: []string{"One.zip", "Two.zip"}},
397 },
398 {
399 name: "long flag takes a separate child name",
400 args: []string{"-n", "--agent", "House", "One.zip"},
401 want: WhatsAppImportOptions{
402 planOnly: true,
403 agent: "House",
404 paths: []string{"One.zip"},
405 },
406 },
407 {
408 name: "flags can follow paths",
409 args: []string{"One.zip", "--agent=House", "-n"},
410 want: WhatsAppImportOptions{
411 planOnly: true,
412 agent: "House",
413 paths: []string{"One.zip"},
414 },
415 },
416 {
417 name: "double dash ends flag parsing",
418 args: []string{"-n", "--", "-Example.zip"},
419 want: WhatsAppImportOptions{
420 planOnly: true,
421 paths: []string{"-Example.zip"},
422 },
423 },
424 }
425
426 for _, tc := range testCases {
427 t.Run(tc.name, func(t *testing.T) {
428 options, err := whatsappImportParse(tc.args)
429 require.NoError(t, err)
430 require.Equal(t, tc.want, options)
431 })
432 }
433 }
434
435 func TestWhatsAppImportRefusesInvalidFlags(t *testing.T) {
436 testCases := []struct {
437 name string
438 args []string
439 want string
440 }{
441 {
442 name: "missing exports are refused",
443 args: []string{"-n", "--agent", "House"},
444 want: "usage:",
445 },
446 {
447 name: "missing child name is refused",
448 args: []string{"--agent"},
449 want: "needs a child name",
450 },
451 {
452 name: "empty child name is refused",
453 args: []string{"--agent=", "One.zip"},
454 want: "needs a child name",
455 },
456 {
457 name: "single dash long flag is refused",
458 args: []string{"-agent", "House", "One.zip"},
459 want: "unknown import flag",
460 },
461 }
462
463 for _, tc := range testCases {
464 t.Run(tc.name, func(t *testing.T) {
465 _, err := whatsappImportParse(tc.args)
466 require.ErrorContains(t, err, tc.want)
467 })
468 }
469 }
470
471 func TestWhatsAppImportChildPlanReadsRootCutoffButNotRootImportedDays(t *testing.T) {
472 root := newRebuildTestAgent(t)
473 dir := filepath.Dir(root.dir)
474 t.Setenv("ONECHAT_DIR", dir)
475
476 childDir := daemonChildConfig(t, dir, "House")
477 rebuildTestLog(t, root.dir, []DatedMessage{
478 {
479 Kind: "work",
480 Text: "[WhatsApp] Plans, Wednesday 2025-01-01 (imported)\n08:00 Ada: Root history",
481 Date: "2025-01-01T08:00:00Z",
482 },
483 {
484 Kind: "work",
485 Text: "[WhatsApp] Plans, group example@g.us, Friday 2025-01-03\n10:00 Ada: Root live\n10:20 Ada: Last",
486 Date: "2025-01-03T10:20:00Z",
487 },
488 })
489 rebuildTestLog(t, childDir, []DatedMessage{
490 {
491 Kind: "work",
492 Text: "[WhatsApp] Plans, Thursday 2025-01-02 (imported)\n08:00 Ada: Child history",
493 Date: "2025-01-02T08:00:00Z",
494 },
495 {
496 Kind: "work",
497 Text: "[WhatsApp] Plans, group example@g.us, Saturday 2025-01-04\n10:00 Ada: Child live",
498 Date: "2025-01-04T10:00:00Z",
499 },
500 })
501
502 path := whatsappImportZip(t, "Plans", "[01/01/2025, 08:00:00] Ada: Root history\n[02/01/2025, 08:00:00] Ada: Child history\n[03/01/2025, 09:59:59] Ada: Before\n[03/01/2025, 10:00:00] Ada: Boundary\n[03/01/2025, 10:01:00] Ada: After\n")
503 before := rebuildTestSnapshot(t, root.dir)
504
505 var report strings.Builder
506
507 require.NoError(t, whatsappImport(t.Context(), []string{"-n", "--agent", "House", path}, &report))
508 require.Contains(t, report.String(), "agent: House\n")
509 require.Contains(t, report.String(), "batches: 2 (1 chat-days skipped)")
510 require.Contains(t, report.String(), "2025-01-03T10:00:00")
511 require.Contains(t, report.String(), "messages: 4 (merged log)")
512 require.Equal(t, before, rebuildTestSnapshot(t, root.dir))
513
514 report.Reset()
515 require.NoError(t, whatsappImport(t.Context(), []string{"-n", path}, &report))
516 require.Contains(t, report.String(), "agent: "+root.config.Name+"\n")
517 require.Contains(t, report.String(), "batches: 2 (1 chat-days skipped)")
518 require.Equal(t, before, rebuildTestSnapshot(t, root.dir))
519 }
520
521 func TestWhatsAppImportChildRebuildUsesOwnPromptAndCache(t *testing.T) {
522 root := newRebuildTestAgent(t)
523 dir := filepath.Dir(root.dir)
524 childDir := daemonChildConfig(t, dir, "House")
525 daemonChildConfig(t, dir, "Calendar")
526 require.NoError(t, os.WriteFile(filepath.Join(childDir, "instructions.md"), []byte("Keep room dimensions exact."), 0o600))
527 rebuildTestLog(t, root.dir, []DatedMessage{{
528 Kind: "user",
529 Text: "Keep the root history.",
530 Date: "2025-02-01T00:00:00Z",
531 }})
532 rebuildTestLog(t, childDir, []DatedMessage{{
533 Kind: "user",
534 Text: "Keep the child history.",
535 Date: "2025-02-02T00:00:00Z",
536 }})
537
538 child, err := whatsappImportAgent(root.dir, "House")
539 require.NoError(t, err)
540
541 old, err := rebuildReadLog(t.Context(), child.dir)
542 require.NoError(t, err)
543
544 rootLog, err := rebuildReadLog(t.Context(), root.dir)
545 require.NoError(t, err)
546
547 path := whatsappImportZip(t, "Plans", "[01/01/2025, 08:00:00] Ada: "+strings.Repeat("The room is ready. ", 80)+"\n")
548 plan, err := whatsappImportPlan(t.Context(), []string{path}, old, rootLog, nil, "", time.UTC)
549 require.NoError(t, err)
550
551 before := rebuildTestSnapshot(t, child.dir)
552 sibling := rebuildTestSnapshot(t, filepath.Join(root.dir, "children", "Calendar"))
553 server := newModelFake(t)
554 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
555 children, err := root.Children()
556 if err != nil || len(children) != 2 {
557 w.WriteHeader(http.StatusBadRequest)
558
559 return
560 }
561
562 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("The room is ready.")}, 100, 0, 5))
563 }
564
565 client := newModelClient(server.URL, ModelAPIKey(modelTestKey), child.CacheKey(), filepath.Join(t.TempDir(), "usage.jsonl"))
566 backup, err := rebuild(t.Context(), child, plan.added, client, newCompactor(client, child.Prompt, defaultConfig().Compactor, child.Tools(), child.Images()), &strings.Builder{})
567 require.NoError(t, err)
568 require.Equal(t, before, rebuildTestSnapshot(t, backup))
569 require.Equal(t, root.dir, filepath.Dir(backup))
570 require.Equal(t, sibling, rebuildTestSnapshot(t, filepath.Join(root.dir, "children", "Calendar")))
571
572 children, err := root.Children()
573 require.NoError(t, err)
574 require.Len(t, children, 2)
575
576 entries, err := rebuildReadLog(t.Context(), child.dir)
577 require.NoError(t, err)
578 require.Equal(t, append(plan.added, old...), entries)
579
580 unchanged, err := rebuildReadLog(t.Context(), root.dir)
581 require.NoError(t, err)
582 require.Equal(t, rootLog, unchanged)
583
584 prompt, err := child.Prompt()
585 require.NoError(t, err)
586 require.NotEqual(t, root.CacheKey(), child.CacheKey())
587 require.Positive(t, server.Calls())
588
589 for i := range server.Calls() {
590 call := server.Call(i)
591 require.Equal(t, child.CacheKey(), call.body["prompt_cache_key"])
592 require.Equal(t, prompt, call.body["instructions"])
593 require.Contains(t, prompt, "Keep room dimensions exact.")
594 require.NotContains(t, string(call.raw), `"name":"whatsapp_send"`)
595 }
596 }
597
598 func TestWhatsAppImportChildFailureKeepsConfiguredChildren(t *testing.T) {
599 root := newRebuildTestAgent(t)
600 daemonChildConfig(t, filepath.Dir(root.dir), "House")
601
602 child, err := whatsappImportAgent(root.dir, "House")
603 require.NoError(t, err)
604
605 before := rebuildTestSnapshot(t, root.dir)
606 server := newModelFake(t)
607 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
608 w.WriteHeader(http.StatusBadRequest)
609 }
610
611 client := newModelClient(server.URL, ModelAPIKey(modelTestKey), child.CacheKey(), filepath.Join(t.TempDir(), "usage.jsonl"))
612 _, err = rebuild(t.Context(), child, []DatedMessage{{
613 Kind: "work",
614 Text: strings.Repeat("The plan is ready. ", 80),
615 Date: "2025-01-01T08:00:00Z",
616 }}, client, newCompactor(client, child.Prompt, defaultConfig().Compactor, child.Tools(), child.Images()), &strings.Builder{})
617 require.ErrorContains(t, err, "rebuild")
618 require.Equal(t, before, rebuildTestSnapshot(t, root.dir))
619
620 children, err := root.Children()
621 require.NoError(t, err)
622 require.Len(t, children, 1)
623 }
624
625 func TestWhatsAppImportRefusesPendingDeliveriesAndKeepsRestartSafe(t *testing.T) {
626 testCases := []struct {
627 name string
628 child bool
629 }{
630 {
631 name: "pending child delivery keeps its log ID",
632 child: true,
633 },
634 {
635 name: "pending root report keeps its log ID",
636 },
637 }
638
639 for _, tc := range testCases {
640 t.Run(tc.name, func(t *testing.T) {
641 dir := t.TempDir()
642 t.Setenv("ONECHAT_DIR", dir)
643
644 server := newModelFake(t)
645 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
646 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
647 }
648
649 test := newDaemonMessageTest(t, dir, server)
650 index := 0
651 args := []string{whatsappImportZip(t, "Plans", "[01/01/2025, 08:00:00] Ada: The room is ready.\n")}
652 if tc.child {
653 index = 1
654 args = append([]string{"--agent", "Garden"}, args...)
655
656 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Keep the plan."))
657 } else {
658 require.NoError(t, test.messages.Finish(test.loops[1], nil, "The plan is ready."))
659 }
660
661 queue := test.Queue(t)
662 require.Len(t, queue.Pending, 1)
663 require.NotNil(t, queue.Pending[0].LogID)
664
665 before, err := test.agents[index].memory.Message(*queue.Pending[0].LogID)
666 require.NoError(t, err)
667 test.Close(t)
668
669 root := filepath.Join(dir, "agent")
670 snapshot := rebuildTestSnapshot(t, root)
671 require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("invalid model settings"), 0o600))
672
673 for _, planOnly := range []bool{true, false} {
674 input := append([]string(nil), args...)
675 if planOnly {
676 input = append([]string{"-n"}, input...)
677 }
678
679 err = whatsappImport(t.Context(), input, &strings.Builder{})
680 require.ErrorContains(t, err, "has pending deliveries")
681 require.Equal(t, snapshot, rebuildTestSnapshot(t, root))
682 require.Zero(t, server.Calls())
683 }
684
685 restarted := newDaemonMessageTest(t, dir, server)
686 require.Equal(t, queue, restarted.Queue(t))
687 require.Equal(t, 1, restarted.agents[index].memory.Len())
688
689 after, err := restarted.agents[index].memory.Message(before.ID)
690 require.NoError(t, err)
691 require.Equal(t, before, after)
692 restarted.Start(t, index)
693 restarted.events[index].WaitFor(t, "idle", 1)
694 require.Empty(t, restarted.Queue(t).Pending)
695 require.Equal(t, 1, server.Calls())
696 restarted.Close(t)
697
698 again := newDaemonMessageTest(t, dir, server)
699 require.False(t, again.loops[index].waiting())
700 require.Equal(t, 1, server.Calls())
701 })
702 }
703 }
704
705 func TestWhatsAppImportPendingDeliveriesBlockOnlyTheirTarget(t *testing.T) {
706 dir := t.TempDir()
707 t.Setenv("ONECHAT_DIR", dir)
708
709 server := newModelFake(t)
710 test := newDaemonMessageTest(t, dir, server)
711 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Keep the plan."))
712
713 queue := test.Queue(t)
714 test.Close(t)
715
716 path := whatsappImportZip(t, "Plans", "[01/01/2025, 08:00:00] Ada: The room is ready.\n")
717 for _, name := range []string{"", "Library"} {
718 args := []string{"-n", path}
719 if name != "" {
720 args = append(args, "--agent", name)
721 }
722
723 require.NoError(t, whatsappImport(t.Context(), args, &strings.Builder{}))
724 }
725
726 queue.Pending[0].LogID = nil
727 data, err := json.Marshal(queue)
728 require.NoError(t, err)
729 require.NoError(t, os.WriteFile(filepath.Join(dir, "agent", "messages.json"), data, 0o600))
730 require.ErrorContains(t, whatsappImport(t.Context(), []string{"-n", "--agent", "Garden", path}, &strings.Builder{}), "has pending deliveries")
731 require.Zero(t, server.Calls())
732 }
733
734 func TestWhatsAppImportDefaultPlanDoesNotCreateAgent(t *testing.T) {
735 dir := t.TempDir()
736 t.Setenv("ONECHAT_DIR", dir)
737
738 var report strings.Builder
739
740 require.NoError(t, whatsappImport(t.Context(), []string{"-n", whatsappImportSample(t)}, &report))
741 require.Contains(t, report.String(), "agent: OneChat\n")
742
743 _, err := os.Stat(filepath.Join(dir, "agent"))
744 require.ErrorIs(t, err, os.ErrNotExist)
745 }
746
747 func whatsappImportSample(t *testing.T) string {
748 t.Helper()
749
750 data, err := os.ReadFile(filepath.Join("testdata", "whatsapp-export.zip"))
751 require.NoError(t, err)
752
753 path := filepath.Join(t.TempDir(), "WhatsApp Chat - Book Club.zip")
754 require.NoError(t, os.WriteFile(path, data, 0o600))
755
756 return path
757 }
758
759 func whatsappImportZip(t *testing.T, name, text string) string {
760 t.Helper()
761
762 path := filepath.Join(t.TempDir(), "WhatsApp Chat - "+name+".zip")
763 file, err := os.Create(path)
764 require.NoError(t, err)
765
766 archive := zip.NewWriter(file)
767 writer, err := archive.Create("_chat.txt")
768 require.NoError(t, err)
769
770 _, err = writer.Write([]byte(text))
771 require.NoError(t, err)
772 require.NoError(t, archive.Close())
773 require.NoError(t, file.Close())
774
775 return path
776 }