@hraness/ghostget 0.18.0 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/README.md +7 -7
  3. package/dist/apple-photos-client.js +2 -1
  4. package/dist/beeper-client.js +2 -1
  5. package/dist/client.js +1 -0
  6. package/dist/imessage-direct-install-2gm3kge7.js +324 -0
  7. package/dist/index-01eeae9e.js +5 -0
  8. package/dist/index-2ymnp8xv.js +145 -0
  9. package/dist/index-en5hycxp.js +175 -0
  10. package/dist/{index-9wca02er.js → index-hfbygww8.js} +1 -1
  11. package/dist/index-n4szk3nw.js +50 -0
  12. package/dist/index-yq6maz71.js +1105 -0
  13. package/dist/index-z1w83f81.js +4 -0
  14. package/dist/index.js +4 -47
  15. package/dist/messaging-automation-api.js +21 -0
  16. package/dist/messaging-automation-j4274hvc.js +609 -0
  17. package/dist/messaging-native-install-8w1j36ah.js +16 -0
  18. package/dist/messaging.js +1 -0
  19. package/dist/omni-client.js +1 -0
  20. package/dist/whatsapp-automation-runtime-hgh1e9rm.js +845 -0
  21. package/dist/whatsapp-client.js +1 -0
  22. package/docs/control-panel.md +2 -2
  23. package/docs/imessage-direct-provider.md +31 -7
  24. package/docs/messaging-automation.md +103 -0
  25. package/package.json +22 -2
  26. package/skills/ghostget/references/control-panel.md +1 -1
  27. package/skills/ghostget/references/install.md +5 -5
  28. package/skills/ghostget/references/linkedin-adapter.md +209 -13
  29. package/skills/ghostget/references/platform-patterns.md +1 -1
  30. package/src/args.ts +18 -5
  31. package/src/assets/adapters/imessage/wrench-web-adapter.json +126 -0
  32. package/src/assets/adapters/linkedin/wrench-web-adapter.json +1 -1
  33. package/src/assets/adapters/whatsapp/wrench-web-adapter.json +144 -0
  34. package/src/assets/messaging-runtime/NOTICE.txt +2580 -0
  35. package/src/assets/messaging-runtime/imsg-darwin-arm64.gz +0 -0
  36. package/src/assets/messaging-runtime/phone-number-info.plist.gz +0 -0
  37. package/src/assets/messaging-runtime/phone-number-metadata.json.gz +0 -0
  38. package/src/assets/messaging-runtime/phone-number-privacy.plist.gz +0 -0
  39. package/src/assets/messaging-runtime/wacli-darwin-arm64.gz +0 -0
  40. package/src/beeper-client-types.ts +1 -1
  41. package/src/ghostget.ts +16 -4
  42. package/src/messaging-automation-api.ts +12 -0
  43. package/src/messaging-automation-descriptors.ts +29 -0
  44. package/src/messaging-automation-factory.ts +148 -0
  45. package/src/messaging-automation-server.ts +183 -0
  46. package/src/messaging-automation-types.ts +155 -0
  47. package/src/messaging-automation-validation.ts +110 -0
  48. package/src/messaging-automation.ts +356 -0
  49. package/src/plugins/imessage-direct/plugin.ts +12 -1
  50. package/src/plugins/imessage-direct/vendor/0003-feat-rpc-add-no-fetch-rich-cards.patch +276 -0
  51. package/src/plugins/imessage-direct/vendor/provenance.json +84 -10
  52. package/src/plugins/linkedin-web/plugin.ts +1 -1
  53. package/src/plugins/whatsapp-linked-device/plugin.ts +7 -2
  54. package/src/plugins/whatsapp-linked-device/vendor/0001-ghostget-private-messaging.patch +1093 -0
  55. package/src/plugins/whatsapp-linked-device/vendor/README.md +39 -0
  56. package/src/plugins/whatsapp-linked-device/vendor/provenance.json +45 -0
  57. package/src/provider-plugin.ts +14 -0
  58. package/src/providers/imessage-automation.ts +250 -0
  59. package/src/providers/imessage-direct-install.ts +7 -0
  60. package/src/providers/imessage-direct-runtime.ts +32 -2
  61. package/src/providers/imessage-direct.ts +13 -3
  62. package/src/providers/linkedin-contact-failure.ts +21 -0
  63. package/src/providers/linkedin-contact-platform.ts +8 -1
  64. package/src/providers/linkedin-contact-program.ts +34 -5
  65. package/src/providers/linkedin-web-contact.ts +1023 -51
  66. package/src/providers/linkedin-web-profile-browser.ts +659 -45
  67. package/src/providers/linkedin-web-runtime.ts +1 -0
  68. package/src/providers/linkedin-web.ts +46 -2
  69. package/src/providers/messaging-native-artifacts.ts +38 -0
  70. package/src/providers/messaging-native-install.ts +75 -0
  71. package/src/providers/whatsapp-automation-runtime.ts +240 -0
  72. package/src/providers/whatsapp-automation.ts +153 -0
  73. package/src/usage.ts +3 -1
  74. package/src/version.ts +1 -1
  75. package/src/web-session-contract-definitions.ts +1 -1
  76. package/tsconfig.json +3 -0
@@ -0,0 +1,1093 @@
1
+ From 16cf7557a1683eb18e7a6e1c1fde8ca9f05e0850 Mon Sep 17 00:00:00 2001
2
+ From: 0thernet <ben@substrate.run>
3
+ Date: Fri, 11 Sep 2026 18:07:14 -0400
4
+ Subject: [PATCH] feat: add bounded Ghostget private messaging transport
5
+
6
+ ---
7
+ cmd/wacli/ghostget_private_context.go | 9 +
8
+ cmd/wacli/ghostget_private_test.go | 238 +++++++++++
9
+ cmd/wacli/ghostget_private_unix.go | 527 +++++++++++++++++++++++++
10
+ cmd/wacli/ghostget_private_windows.go | 13 +
11
+ cmd/wacli/send_helpers.go | 8 +
12
+ cmd/wacli/sync.go | 18 +-
13
+ internal/store/ghostget_events.go | 75 ++++
14
+ internal/store/ghostget_events_test.go | 95 +++++
15
+ 8 files changed, 982 insertions(+), 1 deletion(-)
16
+ create mode 100644 cmd/wacli/ghostget_private_context.go
17
+ create mode 100644 cmd/wacli/ghostget_private_test.go
18
+ create mode 100644 cmd/wacli/ghostget_private_unix.go
19
+ create mode 100644 cmd/wacli/ghostget_private_windows.go
20
+ create mode 100644 internal/store/ghostget_events.go
21
+ create mode 100644 internal/store/ghostget_events_test.go
22
+
23
+ diff --git a/cmd/wacli/ghostget_private_context.go b/cmd/wacli/ghostget_private_context.go
24
+ new file mode 100644
25
+ index 0000000..fb98538
26
+ --- /dev/null
27
+ +++ b/cmd/wacli/ghostget_private_context.go
28
+ @@ -0,0 +1,9 @@
29
+ +package main
30
+ +
31
+ +import "context"
32
+ +
33
+ +type ghostgetPrivateContextKey struct{}
34
+ +
35
+ +func isGhostgetPrivateContext(ctx context.Context) bool {
36
+ + return ctx.Value(ghostgetPrivateContextKey{}) == true
37
+ +}
38
+ diff --git a/cmd/wacli/ghostget_private_test.go b/cmd/wacli/ghostget_private_test.go
39
+ new file mode 100644
40
+ index 0000000..49dff10
41
+ --- /dev/null
42
+ +++ b/cmd/wacli/ghostget_private_test.go
43
+ @@ -0,0 +1,238 @@
44
+ +//go:build !windows
45
+ +
46
+ +package main
47
+ +
48
+ +import (
49
+ + "context"
50
+ + "encoding/json"
51
+ + "errors"
52
+ + "net"
53
+ + "os"
54
+ + "path/filepath"
55
+ + "strings"
56
+ + "sync/atomic"
57
+ + "testing"
58
+ + "time"
59
+ +
60
+ + "go.mau.fi/whatsmeow"
61
+ +)
62
+ +
63
+ +func privateTestDirectory(t *testing.T) string {
64
+ + t.Helper()
65
+ + path, err := os.MkdirTemp("", "ggp-")
66
+ + if err != nil {
67
+ + t.Fatal(err)
68
+ + }
69
+ + path, err = filepath.EvalSymlinks(path)
70
+ + if err != nil {
71
+ + t.Fatal(err)
72
+ + }
73
+ + t.Cleanup(func() { os.RemoveAll(path) })
74
+ + return path
75
+ +}
76
+ +
77
+ +func privateTestRequest() ghostgetPrivateRequest {
78
+ + return ghostgetPrivateRequest{Protocol: ghostgetPrivateProtocol, Kind: "text", RequestID: strings.Repeat("a", 64), Generation: strings.Repeat("b", 64), Account: "15550000001@s.whatsapp.net", To: "15550000002@s.whatsapp.net", Message: "Synthetic test", Options: []string{}}
79
+ +}
80
+ +
81
+ +func TestGhostgetPrivateHasOneJoinedAttempt(t *testing.T) {
82
+ + ctx := context.WithValue(context.Background(), ghostgetPrivateContextKey{}, true)
83
+ + attempts, reconnects := 0, 0
84
+ + _, err := runSendOperation(ctx, func(context.Context) error { reconnects++; return nil }, func(context.Context) (string, error) { attempts++; return "", whatsmeow.ErrIQTimedOut })
85
+ + if !errors.Is(err, whatsmeow.ErrIQTimedOut) || attempts != 1 || reconnects != 0 {
86
+ + t.Fatal("private send retried", attempts, reconnects, err)
87
+ + }
88
+ + cancelled, cancel := context.WithCancel(ctx)
89
+ + entered := make(chan struct{})
90
+ + release := make(chan struct{})
91
+ + done := make(chan struct{})
92
+ + go func() {
93
+ + defer close(done)
94
+ + _, _ = runSendOperation(cancelled, func(context.Context) error { return nil }, func(ctx context.Context) (string, error) {
95
+ + close(entered)
96
+ + <-ctx.Done()
97
+ + <-release
98
+ + return "", ctx.Err()
99
+ + })
100
+ + }()
101
+ + <-entered
102
+ + cancel()
103
+ + select {
104
+ + case <-done:
105
+ + t.Fatal("cancelled operation escaped without joining")
106
+ + case <-time.After(20 * time.Millisecond):
107
+ + }
108
+ + close(release)
109
+ + select {
110
+ + case <-done:
111
+ + case <-time.After(time.Second):
112
+ + t.Fatal("join did not finish")
113
+ + }
114
+ +}
115
+ +
116
+ +func TestGhostgetPrivateClosedRequest(t *testing.T) {
117
+ + raw, _ := json.Marshal(privateTestRequest())
118
+ + if _, err := decodeGhostgetPrivateRequest(raw); err != nil {
119
+ + t.Fatal(err)
120
+ + }
121
+ + for _, bad := range [][]byte{append(raw, []byte(" {}")...), []byte(strings.Replace(string(raw), `"kind":"text"`, `"kind":"text","kind":"text"`, 1)), []byte(strings.Replace(string(raw), `"message":"Synthetic test"`, `"message":null`, 1)), []byte(strings.Replace(string(raw), `"kind":"text"`, `"surprise":true,"kind":"text"`, 1)), []byte(strings.Replace(string(raw), `"options":[]`, `"options":null`, 1)), append(raw, 0xff), []byte(strings.Repeat(" ", ghostgetPrivateMaximum+1))} {
122
+ + if _, err := decodeGhostgetPrivateRequest(bad); err == nil {
123
+ + t.Fatal("accepted malformed private request")
124
+ + }
125
+ + }
126
+ +}
127
+ +
128
+ +func TestGhostgetPrivateDurableExactRequest(t *testing.T) {
129
+ + directory := privateTestDirectory(t)
130
+ + request := privateTestRequest()
131
+ + calls := 0
132
+ + runtime := ghostgetPrivateRuntime{account: func() string { return request.Account }, connected: func() bool { return true }, dispatch: func(ctx context.Context, request ghostgetPrivateRequest) (sendDelegateResponse, error) {
133
+ + calls++
134
+ + if !isGhostgetPrivateContext(ctx) {
135
+ + t.Fatal("not a one-attempt context")
136
+ + }
137
+ + return sendDelegateResponse{OK: true, Sent: true, To: request.To, ID: "synthetic-id"}, nil
138
+ + }}
139
+ + response := executeGhostgetPrivate(context.Background(), runtime, directory, request.Generation, request)
140
+ + if response.State != "accepted" || calls != 1 {
141
+ + t.Fatal(response, calls)
142
+ + }
143
+ + request.Generation = strings.Repeat("c", 64)
144
+ + response = executeGhostgetPrivate(context.Background(), runtime, directory, request.Generation, request)
145
+ + if response.State != "accepted" || calls != 1 || response.Generation != request.Generation {
146
+ + t.Fatal("restart replay dispatched", response, calls)
147
+ + }
148
+ + request.Message = "different"
149
+ + if response = executeGhostgetPrivate(context.Background(), runtime, directory, request.Generation, request); response.State != "indeterminate" || calls != 1 {
150
+ + t.Fatal("conflicting replay dispatched")
151
+ + }
152
+ + request.RequestID = strings.Repeat("d", 64)
153
+ + request.Message = "synthetic failure"
154
+ + runtime.dispatch = func(context.Context, ghostgetPrivateRequest) (sendDelegateResponse, error) {
155
+ + calls++
156
+ + return sendDelegateResponse{}, errors.New("unknown")
157
+ + }
158
+ + if response = executeGhostgetPrivate(context.Background(), runtime, directory, request.Generation, request); response.State != "indeterminate" || calls != 2 {
159
+ + t.Fatal(response, calls)
160
+ + }
161
+ + if response = executeGhostgetPrivate(context.Background(), runtime, directory, request.Generation, request); response.State != "indeterminate" || calls != 2 {
162
+ + t.Fatal("uncertain replay retried")
163
+ + }
164
+ +}
165
+ +
166
+ +func TestGhostgetPrivateRejectsIdentityAndTarget(t *testing.T) {
167
+ + request := privateTestRequest()
168
+ + directory := privateTestDirectory(t)
169
+ + calls := 0
170
+ + runtime := ghostgetPrivateRuntime{account: func() string { return "15550000001@s.whatsapp.net" }, connected: func() bool { return true }, dispatch: func(context.Context, ghostgetPrivateRequest) (sendDelegateResponse, error) {
171
+ + calls++
172
+ + return sendDelegateResponse{}, nil
173
+ + }}
174
+ + for _, change := range []func(*ghostgetPrivateRequest){func(r *ghostgetPrivateRequest) { r.Generation = "other" }, func(r *ghostgetPrivateRequest) { r.Account = "15550000003@s.whatsapp.net" }, func(r *ghostgetPrivateRequest) { r.To = r.Account }, func(r *ghostgetPrivateRequest) { r.To = "15550000002:3@s.whatsapp.net" }, func(r *ghostgetPrivateRequest) { r.To = "15550000002@g.us" }, func(r *ghostgetPrivateRequest) { r.ID = "unexpected" }} {
175
+ + current := request
176
+ + change(&current)
177
+ + response := executeGhostgetPrivate(context.Background(), runtime, directory, request.Generation, current)
178
+ + if response.State != "not-started" || calls != 0 {
179
+ + t.Fatal("identity or target admitted", response)
180
+ + }
181
+ + }
182
+ +}
183
+ +
184
+ +func TestGhostgetPrivateSocketShutdownJoins(t *testing.T) {
185
+ + directory := privateTestDirectory(t)
186
+ + var calls atomic.Int32
187
+ + entered := make(chan struct{})
188
+ + release := make(chan struct{})
189
+ + runtime := ghostgetPrivateRuntime{store: directory, account: func() string { return "15550000001@s.whatsapp.net" }, connected: func() bool { return true }, dispatch: func(ctx context.Context, r ghostgetPrivateRequest) (sendDelegateResponse, error) {
190
+ + calls.Add(1)
191
+ + close(entered)
192
+ + <-ctx.Done()
193
+ + <-release
194
+ + return sendDelegateResponse{}, ctx.Err()
195
+ + }}
196
+ + stop, err := startGhostgetPrivateRuntime(context.Background(), runtime)
197
+ + if err != nil {
198
+ + t.Fatal(err)
199
+ + }
200
+ + path := filepath.Join(directory, ghostgetPrivateSocket)
201
+ + idle, err := net.Dial("unix", path)
202
+ + if err != nil {
203
+ + t.Fatal(err)
204
+ + }
205
+ + defer idle.Close()
206
+ + invoke := func(request ghostgetPrivateRequest) ghostgetPrivateResponse {
207
+ + conn, err := net.Dial("unix", path)
208
+ + if err != nil {
209
+ + t.Fatal(err)
210
+ + }
211
+ + defer conn.Close()
212
+ + conn.SetDeadline(time.Now().Add(time.Second))
213
+ + json.NewEncoder(conn).Encode(request)
214
+ + var response ghostgetPrivateResponse
215
+ + if err = json.NewDecoder(conn).Decode(&response); err != nil {
216
+ + t.Fatal(err)
217
+ + }
218
+ + return response
219
+ + }
220
+ + status := invoke(ghostgetPrivateRequest{Protocol: ghostgetPrivateProtocol, Kind: "status", Options: []string{}})
221
+ + if status.State != "ready" {
222
+ + t.Fatal(status)
223
+ + }
224
+ + request := privateTestRequest()
225
+ + request.Generation = status.Generation
226
+ + active, err := net.Dial("unix", path)
227
+ + if err != nil {
228
+ + t.Fatal(err)
229
+ + }
230
+ + defer active.Close()
231
+ + json.NewEncoder(active).Encode(request)
232
+ + <-entered
233
+ + done := make(chan struct{})
234
+ + go func() { stop(); close(done) }()
235
+ + select {
236
+ + case <-done:
237
+ + t.Fatal("shutdown abandoned dispatch")
238
+ + case <-time.After(20 * time.Millisecond):
239
+ + }
240
+ + close(release)
241
+ + select {
242
+ + case <-done:
243
+ + case <-time.After(time.Second):
244
+ + t.Fatal("shutdown hung on idle reader")
245
+ + }
246
+ + if calls.Load() != 1 {
247
+ + t.Fatal("unexpected dispatch count")
248
+ + }
249
+ + if _, err = os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
250
+ + t.Fatal("owned socket remains")
251
+ + }
252
+ + stop()
253
+ +}
254
+ +
255
+ +func TestGhostgetPrivateExternalSocketIsOwnedAndNeverReplaced(t *testing.T) {
256
+ + store := privateTestDirectory(t)
257
+ + owner := privateTestDirectory(t)
258
+ + path := filepath.Join(owner, ".s")
259
+ + runtime := ghostgetPrivateRuntime{store: store, socketPath: path, account: func() string { return "15550000001@s.whatsapp.net" }, connected: func() bool { return true }}
260
+ + if err := os.WriteFile(path, []byte("unknown"), 0600); err != nil {
261
+ + t.Fatal(err)
262
+ + }
263
+ + if _, err := startGhostgetPrivateRuntime(context.Background(), runtime); err == nil {
264
+ + t.Fatal("replaced an unknown existing entry")
265
+ + }
266
+ + bytes, err := os.ReadFile(path)
267
+ + if err != nil || string(bytes) != "unknown" {
268
+ + t.Fatal("existing entry changed")
269
+ + }
270
+ + if err = os.Remove(path); err != nil {
271
+ + t.Fatal(err)
272
+ + }
273
+ + stop, err := startGhostgetPrivateRuntime(context.Background(), runtime)
274
+ + if err != nil {
275
+ + t.Fatal(err)
276
+ + }
277
+ + stop()
278
+ + if _, err = os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
279
+ + t.Fatal("owned external socket remains")
280
+ + }
281
+ +}
282
+ diff --git a/cmd/wacli/ghostget_private_unix.go b/cmd/wacli/ghostget_private_unix.go
283
+ new file mode 100644
284
+ index 0000000..16c7eeb
285
+ --- /dev/null
286
+ +++ b/cmd/wacli/ghostget_private_unix.go
287
+ @@ -0,0 +1,527 @@
288
+ +//go:build !windows
289
+ +
290
+ +package main
291
+ +
292
+ +import (
293
+ + "bufio"
294
+ + "bytes"
295
+ + "context"
296
+ + "crypto/rand"
297
+ + "crypto/sha256"
298
+ + "encoding/hex"
299
+ + "encoding/json"
300
+ + "errors"
301
+ + "io"
302
+ + "net"
303
+ + "os"
304
+ + "path/filepath"
305
+ + "regexp"
306
+ + "strconv"
307
+ + "sync"
308
+ + "syscall"
309
+ + "time"
310
+ + "unicode/utf8"
311
+ +
312
+ + "github.com/openclaw/wacli/internal/app"
313
+ + "go.mau.fi/whatsmeow/types"
314
+ +)
315
+ +
316
+ +const ghostgetPrivateProtocol = "ghostget.whatsapp-private/1"
317
+ +const ghostgetPrivateSocket = ".ghostget-send.sock"
318
+ +const ghostgetPrivateMaximum = 1024 * 1024
319
+ +
320
+ +var ghostgetRequestID = regexp.MustCompile(`^[a-f0-9]{64}$`)
321
+ +var ghostgetDirectJID = regexp.MustCompile(`^([1-9][0-9]{4,14}@s\.whatsapp\.net|[1-9][0-9]{4,19}@lid)$`)
322
+ +
323
+ +type ghostgetPrivateRequest struct {
324
+ + Protocol string `json:"protocol"`
325
+ + Kind string `json:"kind"`
326
+ + RequestID string `json:"requestId"`
327
+ + Generation string `json:"generation"`
328
+ + Account string `json:"account"`
329
+ + To string `json:"to"`
330
+ + Message string `json:"message"`
331
+ + File string `json:"file"`
332
+ + Filename string `json:"filename"`
333
+ + MIME string `json:"mime"`
334
+ + ID string `json:"id"`
335
+ + Reaction string `json:"reaction"`
336
+ + Question string `json:"question"`
337
+ + Options []string `json:"options"`
338
+ + Selectable int `json:"selectable"`
339
+ +}
340
+ +
341
+ +type ghostgetPrivateResponse struct {
342
+ + Protocol string `json:"protocol"`
343
+ + RequestID string `json:"requestId"`
344
+ + Generation string `json:"generation"`
345
+ + Account string `json:"account"`
346
+ + State string `json:"state"`
347
+ + To string `json:"to"`
348
+ + MessageID string `json:"messageId"`
349
+ + Connected bool `json:"connected"`
350
+ +}
351
+ +
352
+ +type ghostgetPrivateReceipt struct {
353
+ + SchemaVersion int `json:"schemaVersion"`
354
+ + RequestDigest string `json:"requestDigest"`
355
+ + Response ghostgetPrivateResponse `json:"response"`
356
+ +}
357
+ +
358
+ +// The concrete runtime owns the linked-device connection. The transport only
359
+ +// admits one exact action; this small port permits offline custody tests.
360
+ +type ghostgetPrivateRuntime struct {
361
+ + store string
362
+ + socketPath string
363
+ + account func() string
364
+ + connected func() bool
365
+ + dispatch func(context.Context, ghostgetPrivateRequest) (sendDelegateResponse, error)
366
+ +}
367
+ +
368
+ +func ghostgetPrivateOwned(info os.FileInfo, directory bool) bool {
369
+ + stat, ok := info.Sys().(*syscall.Stat_t)
370
+ + if !ok || stat.Uid != uint32(os.Getuid()) || info.Mode()&os.ModeSymlink != 0 {
371
+ + return false
372
+ + }
373
+ + if directory {
374
+ + return info.IsDir() && info.Mode().Perm() == 0700
375
+ + }
376
+ + return info.Mode().IsRegular() && info.Mode().Perm() == 0600 && stat.Nlink == 1
377
+ +}
378
+ +
379
+ +func ghostgetPrivateDirectory(path string) error {
380
+ + if err := os.Mkdir(path, 0700); err != nil && !errors.Is(err, os.ErrExist) {
381
+ + return err
382
+ + }
383
+ + info, err := os.Lstat(path)
384
+ + if err != nil || !ghostgetPrivateOwned(info, true) {
385
+ + return errors.New("unsafe Ghostget transport directory")
386
+ + }
387
+ + resolved, err := filepath.EvalSymlinks(path)
388
+ + if err != nil || resolved != path {
389
+ + return errors.New("noncanonical Ghostget transport directory")
390
+ + }
391
+ + return nil
392
+ +}
393
+ +
394
+ +func ghostgetPrivateSyncDirectory(path string) error {
395
+ + f, err := os.Open(path)
396
+ + if err != nil {
397
+ + return err
398
+ + }
399
+ + defer f.Close()
400
+ + return f.Sync()
401
+ +}
402
+ +
403
+ +func decodeGhostgetPrivateRequest(data []byte) (ghostgetPrivateRequest, error) {
404
+ + var request ghostgetPrivateRequest
405
+ + if len(data) > ghostgetPrivateMaximum || !utf8.Valid(data) || !ghostgetPrivateUnicode(data) {
406
+ + return request, errors.New("invalid request encoding or size")
407
+ + }
408
+ + // Reject duplicate top-level fields before decoding the closed flat schema.
409
+ + decoder := json.NewDecoder(bytes.NewReader(data))
410
+ + token, err := decoder.Token()
411
+ + if err != nil || token != json.Delim('{') {
412
+ + return request, errors.New("invalid request")
413
+ + }
414
+ + seen := map[string]bool{}
415
+ + for decoder.More() {
416
+ + key, err := decoder.Token()
417
+ + if err != nil {
418
+ + return request, errors.New("invalid request")
419
+ + }
420
+ + name, ok := key.(string)
421
+ + if !ok || seen[name] {
422
+ + return request, errors.New("duplicate request field")
423
+ + }
424
+ + seen[name] = true
425
+ + var raw json.RawMessage
426
+ + if decoder.Decode(&raw) != nil {
427
+ + return request, errors.New("invalid request field")
428
+ + }
429
+ + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
430
+ + return request, errors.New("null request field")
431
+ + }
432
+ + }
433
+ + if _, err = decoder.Token(); err != nil {
434
+ + return request, errors.New("invalid request")
435
+ + }
436
+ + var trailing any
437
+ + if err = decoder.Decode(&trailing); err != io.EOF {
438
+ + return request, errors.New("trailing request data")
439
+ + }
440
+ + decoder = json.NewDecoder(bytes.NewReader(data))
441
+ + decoder.DisallowUnknownFields()
442
+ + if err = decoder.Decode(&request); err != nil {
443
+ + return request, errors.New("unsupported request schema")
444
+ + }
445
+ + if len(seen) != 15 || request.Protocol != ghostgetPrivateProtocol {
446
+ + return request, errors.New("unsupported request protocol")
447
+ + }
448
+ + return request, nil
449
+ +}
450
+ +
451
+ +func ghostgetPrivateUnicode(data []byte) bool {
452
+ + for i := 0; i < len(data); i++ {
453
+ + if data[i] != '\\' {
454
+ + continue
455
+ + }
456
+ + i++
457
+ + if i >= len(data) {
458
+ + return false
459
+ + }
460
+ + if data[i] != 'u' {
461
+ + continue
462
+ + }
463
+ + if i+4 >= len(data) {
464
+ + return false
465
+ + }
466
+ + code, err := strconv.ParseUint(string(data[i+1:i+5]), 16, 16)
467
+ + if err != nil {
468
+ + return false
469
+ + }
470
+ + i += 4
471
+ + if code >= 0xDC00 && code <= 0xDFFF {
472
+ + return false
473
+ + }
474
+ + if code >= 0xD800 && code <= 0xDBFF {
475
+ + if i+6 >= len(data) || data[i+1] != '\\' || data[i+2] != 'u' {
476
+ + return false
477
+ + }
478
+ + low, err := strconv.ParseUint(string(data[i+3:i+7]), 16, 16)
479
+ + if err != nil || low < 0xDC00 || low > 0xDFFF {
480
+ + return false
481
+ + }
482
+ + i += 6
483
+ + }
484
+ + }
485
+ + return true
486
+ +}
487
+ +
488
+ +func startGhostgetPrivateServer(parent context.Context, a *app.App, socketPath string) (func(), error) {
489
+ + if err := a.DB().EnableGhostgetAutomationEvents(); err != nil {
490
+ + return nil, err
491
+ + }
492
+ + runtime := ghostgetPrivateRuntime{store: a.StoreDir(), account: func() string { return ghostgetPrivateAccount(a) }, connected: a.WA().IsConnected,
493
+ + socketPath: socketPath,
494
+ + dispatch: func(ctx context.Context, request ghostgetPrivateRequest) (sendDelegateResponse, error) {
495
+ + delegated := sendDelegateRequest{Version: 1, Kind: request.Kind, To: request.To, Message: request.Message, NoPreview: true, File: request.File, Filename: request.Filename, MIME: request.MIME, As: "document", ID: request.ID, Reaction: request.Reaction, Question: request.Question, Options: request.Options, Selectable: request.Selectable, PostSendWaitMS: 2000, TimeoutMS: 50000}
496
+ + if request.Kind == "react" {
497
+ + message, err := a.DB().GetMessage(request.To, request.ID)
498
+ + if err != nil {
499
+ + return sendDelegateResponse{}, err
500
+ + }
501
+ + delegated.Sender = message.SenderJID
502
+ + if message.FromMe {
503
+ + delegated.Sender = ghostgetPrivateAccount(a)
504
+ + }
505
+ + }
506
+ + return executeDelegatedSend(ctx, a, delegated)
507
+ + }}
508
+ + return startGhostgetPrivateRuntime(parent, runtime)
509
+ +}
510
+ +
511
+ +func startGhostgetPrivateRuntime(parent context.Context, runtime ghostgetPrivateRuntime) (func(), error) {
512
+ + if err := ghostgetPrivateDirectory(runtime.store); err != nil {
513
+ + return nil, err
514
+ + }
515
+ + journal := filepath.Join(runtime.store, ".ghostget-dispatch-v1")
516
+ + if err := ghostgetPrivateDirectory(journal); err != nil {
517
+ + return nil, err
518
+ + }
519
+ + path := filepath.Join(runtime.store, ghostgetPrivateSocket)
520
+ + if runtime.socketPath != "" {
521
+ + path = runtime.socketPath
522
+ + if !filepath.IsAbs(path) || filepath.Base(path) != ".s" {
523
+ + return nil, errors.New("invalid private socket path")
524
+ + }
525
+ + if err := ghostgetPrivateDirectory(filepath.Dir(path)); err != nil {
526
+ + return nil, err
527
+ + }
528
+ + }
529
+ + if len(path) > 100 {
530
+ + return nil, errors.New("private socket path exceeds the Unix address bound")
531
+ + }
532
+ + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
533
+ + return nil, errors.New("Ghostget transport socket already exists; exact owner reconciliation is required")
534
+ + }
535
+ + listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
536
+ + if err != nil {
537
+ + return nil, err
538
+ + }
539
+ + listener.SetUnlinkOnClose(false)
540
+ + if err = os.Chmod(path, 0600); err != nil {
541
+ + listener.Close()
542
+ + return nil, err
543
+ + }
544
+ + socketIdentity, err := os.Lstat(path)
545
+ + if err != nil {
546
+ + listener.Close()
547
+ + return nil, err
548
+ + }
549
+ + var nonce [32]byte
550
+ + if _, err = rand.Read(nonce[:]); err != nil {
551
+ + listener.Close()
552
+ + return nil, err
553
+ + }
554
+ + generation := hex.EncodeToString(nonce[:])
555
+ + ctx, cancel := context.WithCancel(parent)
556
+ + var workers sync.WaitGroup
557
+ + done := make(chan struct{})
558
+ + capacity := make(chan struct{}, 16)
559
+ + serial := make(chan struct{}, 1)
560
+ + var connectionsMu sync.Mutex
561
+ + connections := map[*net.UnixConn]bool{}
562
+ + go func() {
563
+ + defer close(done)
564
+ + for {
565
+ + conn, err := listener.AcceptUnix()
566
+ + if err != nil {
567
+ + return
568
+ + }
569
+ + select {
570
+ + case capacity <- struct{}{}:
571
+ + default:
572
+ + conn.Close()
573
+ + continue
574
+ + }
575
+ + connectionsMu.Lock()
576
+ + connections[conn] = true
577
+ + connectionsMu.Unlock()
578
+ + workers.Add(1)
579
+ + go func() {
580
+ + defer workers.Done()
581
+ + defer func() { <-capacity }()
582
+ + defer func() { connectionsMu.Lock(); delete(connections, conn); connectionsMu.Unlock(); conn.Close() }()
583
+ + _ = conn.SetDeadline(time.Now().Add(60 * time.Second))
584
+ + line, err := bufio.NewReaderSize(conn, ghostgetPrivateMaximum+1).ReadSlice('\n')
585
+ + if err != nil || len(line) > ghostgetPrivateMaximum {
586
+ + return
587
+ + }
588
+ + request, err := decodeGhostgetPrivateRequest(line)
589
+ + if err != nil {
590
+ + return
591
+ + }
592
+ + requestCtx, finish := context.WithTimeout(ctx, 50*time.Second)
593
+ + defer finish()
594
+ + select {
595
+ + case serial <- struct{}{}:
596
+ + case <-requestCtx.Done():
597
+ + return
598
+ + }
599
+ + defer func() { <-serial }()
600
+ + response := executeGhostgetPrivate(requestCtx, runtime, journal, generation, request)
601
+ + _ = json.NewEncoder(conn).Encode(response)
602
+ + }()
603
+ + }
604
+ + }()
605
+ + var stopOnce sync.Once
606
+ + return func() {
607
+ + stopOnce.Do(func() {
608
+ + cancel()
609
+ + _ = listener.Close()
610
+ + <-done
611
+ + connectionsMu.Lock()
612
+ + for conn := range connections {
613
+ + _ = conn.Close()
614
+ + }
615
+ + connectionsMu.Unlock()
616
+ + workers.Wait()
617
+ + if current, err := os.Lstat(path); err == nil && os.SameFile(socketIdentity, current) {
618
+ + _ = os.Remove(path)
619
+ + }
620
+ + })
621
+ + }, nil
622
+ +}
623
+ +
624
+ +func ghostgetPrivateAccount(a *app.App) string {
625
+ + jid, err := types.ParseJID(a.WA().LinkedJID())
626
+ + if err != nil {
627
+ + return ""
628
+ + }
629
+ + return jid.ToNonAD().String()
630
+ +}
631
+ +
632
+ +func validateGhostgetPrivateSend(request ghostgetPrivateRequest) error {
633
+ + if !ghostgetRequestID.MatchString(request.RequestID) || !ghostgetDirectJID.MatchString(request.To) || !ghostgetDirectJID.MatchString(request.Account) || len(request.Message) > 65536 || len(request.Filename) > 255 || len(request.MIME) > 128 || len(request.ID) > 256 || len(request.Reaction) > 64 {
634
+ + return errors.New("invalid private send request")
635
+ + }
636
+ + if request.To == request.Account {
637
+ + return errors.New("self messaging requires a separate contract")
638
+ + }
639
+ + if request.Kind != "text" && request.Message != "" || request.Kind != "file" && (request.Filename != "" || request.MIME != "") || request.Kind != "react" && (request.ID != "" || request.Reaction != "") || request.Kind != "poll" && (request.Question != "" || len(request.Options) != 0 || request.Selectable != 0) || request.Kind != "file" && request.Kind != "sticker" && request.File != "" {
640
+ + return errors.New("unsupported private action fields")
641
+ + }
642
+ + switch request.Kind {
643
+ + case "text":
644
+ + if request.Message == "" {
645
+ + return errors.New("empty message")
646
+ + }
647
+ + case "file", "sticker":
648
+ + if !filepath.IsAbs(request.File) {
649
+ + return errors.New("file must be an owned absolute path")
650
+ + }
651
+ + info, err := os.Lstat(request.File)
652
+ + if err != nil || !ghostgetPrivateOwned(info, false) || info.Size() < 1 || info.Size() > 20*1024*1024 {
653
+ + return errors.New("file is not a bounded owned asset")
654
+ + }
655
+ + canonical, err := filepath.EvalSymlinks(request.File)
656
+ + if err != nil || canonical != request.File {
657
+ + return errors.New("file path is not canonical")
658
+ + }
659
+ + case "react":
660
+ + if request.ID == "" {
661
+ + return errors.New("missing reaction target")
662
+ + }
663
+ + case "poll":
664
+ + if request.Question == "" || len(request.Question) > 1024 || len(request.Options) < 2 || len(request.Options) > 12 || request.Selectable < 1 || request.Selectable > len(request.Options) {
665
+ + return errors.New("invalid poll")
666
+ + }
667
+ + seen := map[string]bool{}
668
+ + for _, option := range request.Options {
669
+ + if option == "" || len(option) > 256 || seen[option] {
670
+ + return errors.New("invalid poll option")
671
+ + }
672
+ + seen[option] = true
673
+ + }
674
+ + default:
675
+ + return errors.New("unsupported private action")
676
+ + }
677
+ + return nil
678
+ +}
679
+ +
680
+ +func executeGhostgetPrivate(ctx context.Context, runtime ghostgetPrivateRuntime, directory, generation string, request ghostgetPrivateRequest) ghostgetPrivateResponse {
681
+ + response := ghostgetPrivateResponse{Protocol: ghostgetPrivateProtocol, RequestID: request.RequestID, Generation: generation, Account: runtime.account(), State: "not-started", To: request.To, Connected: runtime.connected()}
682
+ + if request.Kind == "status" {
683
+ + if request.RequestID == "" && request.Generation == "" && request.Account == "" && request.To == "" && request.Message == "" && request.File == "" && request.Filename == "" && request.MIME == "" && request.ID == "" && request.Reaction == "" && request.Question == "" && len(request.Options) == 0 && request.Selectable == 0 {
684
+ + response.State = "ready"
685
+ + }
686
+ + return response
687
+ + }
688
+ + if request.Generation != generation || request.Account != response.Account || ctx.Err() != nil || !response.Connected {
689
+ + return response
690
+ + }
691
+ + if validateGhostgetPrivateSend(request) != nil {
692
+ + return response
693
+ + }
694
+ + // Exclude the process generation so an exact request can read its prior outcome
695
+ + // after restart. Account/target/action/request ID remain part of the digest.
696
+ + digestRequest := request
697
+ + digestRequest.Generation = ""
698
+ + encoded, _ := json.Marshal(digestRequest)
699
+ + digest := sha256.Sum256(encoded)
700
+ + receipt := ghostgetPrivateReceipt{SchemaVersion: 1, RequestDigest: hex.EncodeToString(digest[:]), Response: response}
701
+ + receipt.Response.State = "started"
702
+ + path := filepath.Join(directory, request.RequestID+".json")
703
+ + fd, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW, 0600)
704
+ + if errors.Is(err, os.ErrExist) {
705
+ + prior, readErr := readGhostgetPrivateReceipt(path)
706
+ + if readErr != nil || prior.RequestDigest != receipt.RequestDigest || prior.Response.RequestID != request.RequestID || prior.Response.Account != request.Account || prior.Response.To != request.To {
707
+ + response.State = "indeterminate"
708
+ + return response
709
+ + }
710
+ + response = prior.Response
711
+ + response.Generation = generation
712
+ + if response.State != "accepted" {
713
+ + response.State = "indeterminate"
714
+ + }
715
+ + return response
716
+ + }
717
+ + if err != nil {
718
+ + return response
719
+ + }
720
+ + bytes, err := json.Marshal(receipt)
721
+ + if err == nil {
722
+ + _, err = fd.Write(append(bytes, '\n'))
723
+ + }
724
+ + if err == nil {
725
+ + err = fd.Sync()
726
+ + }
727
+ + closeErr := fd.Close()
728
+ + if err == nil {
729
+ + err = closeErr
730
+ + }
731
+ + if err == nil {
732
+ + err = ghostgetPrivateSyncDirectory(directory)
733
+ + }
734
+ + if err != nil {
735
+ + response.State = "indeterminate"
736
+ + return response
737
+ + }
738
+ + if ctx.Err() != nil {
739
+ + response.State = "indeterminate"
740
+ + return response
741
+ + }
742
+ + privateCtx := context.WithValue(ctx, ghostgetPrivateContextKey{}, true)
743
+ + result, err := runtime.dispatch(privateCtx, request)
744
+ + response.State = "indeterminate"
745
+ + if err == nil && result.OK && result.Sent && result.To == request.To && result.ID != "" {
746
+ + response.State = "accepted"
747
+ + response.MessageID = result.ID
748
+ + }
749
+ + receipt.Response = response
750
+ + if err := writeGhostgetPrivateReceipt(path, receipt); err != nil {
751
+ + response.State = "indeterminate"
752
+ + }
753
+ + return response
754
+ +}
755
+ +
756
+ +func readGhostgetPrivateReceipt(path string) (ghostgetPrivateReceipt, error) {
757
+ + var receipt ghostgetPrivateReceipt
758
+ + fd, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
759
+ + if err != nil {
760
+ + return receipt, err
761
+ + }
762
+ + defer fd.Close()
763
+ + info, err := fd.Stat()
764
+ + if err != nil || !ghostgetPrivateOwned(info, false) || info.Size() > 4096 {
765
+ + return receipt, errors.New("unsafe receipt")
766
+ + }
767
+ + data, err := io.ReadAll(io.LimitReader(fd, 4097))
768
+ + if err != nil {
769
+ + return receipt, err
770
+ + }
771
+ + decoder := json.NewDecoder(bytes.NewReader(data))
772
+ + decoder.DisallowUnknownFields()
773
+ + if err = decoder.Decode(&receipt); err != nil || receipt.SchemaVersion != 1 || !ghostgetRequestID.MatchString(receipt.RequestDigest) || receipt.Response.Protocol != ghostgetPrivateProtocol || !ghostgetRequestID.MatchString(receipt.Response.RequestID) || !ghostgetRequestID.MatchString(receipt.Response.Generation) || !ghostgetDirectJID.MatchString(receipt.Response.Account) || !ghostgetDirectJID.MatchString(receipt.Response.To) || len(receipt.Response.MessageID) > 256 || (receipt.Response.State != "started" && receipt.Response.State != "accepted" && receipt.Response.State != "indeterminate") || (receipt.Response.State == "accepted" && receipt.Response.MessageID == "") {
774
+ + return receipt, errors.New("invalid receipt")
775
+ + }
776
+ + var trailing any
777
+ + if decoder.Decode(&trailing) != io.EOF {
778
+ + return receipt, errors.New("trailing receipt data")
779
+ + }
780
+ + return receipt, nil
781
+ +}
782
+ +
783
+ +func writeGhostgetPrivateReceipt(path string, receipt ghostgetPrivateReceipt) error {
784
+ + data, err := json.Marshal(receipt)
785
+ + if err != nil {
786
+ + return err
787
+ + }
788
+ + temporary, err := os.CreateTemp(filepath.Dir(path), ".receipt-")
789
+ + if err != nil {
790
+ + return err
791
+ + }
792
+ + name := temporary.Name()
793
+ + defer os.Remove(name)
794
+ + if err = temporary.Chmod(0600); err == nil {
795
+ + _, err = temporary.Write(append(data, '\n'))
796
+ + }
797
+ + if err == nil {
798
+ + err = temporary.Sync()
799
+ + }
800
+ + closeErr := temporary.Close()
801
+ + if err == nil {
802
+ + err = closeErr
803
+ + }
804
+ + if err != nil {
805
+ + return err
806
+ + }
807
+ + if _, err = readGhostgetPrivateReceipt(path); err != nil {
808
+ + return err
809
+ + }
810
+ + if err = os.Rename(name, path); err != nil {
811
+ + return err
812
+ + }
813
+ + return ghostgetPrivateSyncDirectory(filepath.Dir(path))
814
+ +}
815
+ diff --git a/cmd/wacli/ghostget_private_windows.go b/cmd/wacli/ghostget_private_windows.go
816
+ new file mode 100644
817
+ index 0000000..af833c6
818
+ --- /dev/null
819
+ +++ b/cmd/wacli/ghostget_private_windows.go
820
+ @@ -0,0 +1,13 @@
821
+ +//go:build windows
822
+ +
823
+ +package main
824
+ +
825
+ +import (
826
+ + "context"
827
+ + "errors"
828
+ + "github.com/openclaw/wacli/internal/app"
829
+ +)
830
+ +
831
+ +func startGhostgetPrivateServer(context.Context, *app.App, string) (func(), error) {
832
+ + return nil, errors.New("Ghostget private transport requires a Unix host")
833
+ +}
834
+ diff --git a/cmd/wacli/send_helpers.go b/cmd/wacli/send_helpers.go
835
+ index d16bbdf..96d6a4c 100644
836
+ --- a/cmd/wacli/send_helpers.go
837
+ +++ b/cmd/wacli/send_helpers.go
838
+ @@ -26,6 +26,11 @@ func runSendOperation[T any](
839
+ reconnect func(context.Context) error,
840
+ op func(context.Context) (T, error),
841
+ ) (T, error) {
842
+ + if isGhostgetPrivateContext(ctx) {
843
+ + // This supervised path makes one attempt and joins it. No retry or detached
844
+ + // timeout goroutine can create an unjournaled second message.
845
+ + return op(ctx)
846
+ + }
847
+ result, err := runSendAttempt(ctx, sendAttemptTimeout, op)
848
+ if err == nil {
849
+ return result, nil
850
+ @@ -149,6 +154,9 @@ type whatsappRegistrationResolver interface {
851
+ }
852
+
853
+ func warmupRecipient(ctx context.Context, wa userInfoResolver, jid types.JID, stderr io.Writer) types.JID {
854
+ + if isGhostgetPrivateContext(ctx) {
855
+ + return jid
856
+ + }
857
+ if jid.Server != types.DefaultUserServer && jid.Server != types.HiddenUserServer {
858
+ return jid
859
+ }
860
+ diff --git a/cmd/wacli/sync.go b/cmd/wacli/sync.go
861
+ index 1eacff0..855c47a 100644
862
+ --- a/cmd/wacli/sync.go
863
+ +++ b/cmd/wacli/sync.go
864
+ @@ -25,12 +25,20 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
865
+ var webhookURL string
866
+ var webhookSecret string
867
+ var webhookAllowPrivate bool
868
+ + var ghostgetPrivate bool
869
+ + var ghostgetSocket string
870
+ var storage syncStorageLimitFlags
871
+
872
+ cmd := &cobra.Command{
873
+ Use: "sync",
874
+ Short: "Sync messages (requires prior auth; never shows QR)",
875
+ RunE: func(cmd *cobra.Command, args []string) error {
876
+ + if ghostgetSocket != "" && !ghostgetPrivate {
877
+ + return fmt.Errorf("--ghostget-private-socket requires --ghostget-private-transport")
878
+ + }
879
+ + if ghostgetPrivate && (once || !follow) {
880
+ + return fmt.Errorf("Ghostget private transport requires follow mode")
881
+ + }
882
+ if err := flags.requireWritable(); err != nil {
883
+ return err
884
+ }
885
+ @@ -83,7 +91,13 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
886
+ var afterConnect func(context.Context) error
887
+ if mode == appPkg.SyncModeFollow {
888
+ afterConnect = func(ctx context.Context) error {
889
+ - stop, err := startSendDelegateServer(ctx, a)
890
+ + var stop func()
891
+ + var err error
892
+ + if ghostgetPrivate {
893
+ + stop, err = startGhostgetPrivateServer(ctx, a, ghostgetSocket)
894
+ + } else {
895
+ + stop, err = startSendDelegateServer(ctx, a)
896
+ + }
897
+ if err != nil {
898
+ return err
899
+ }
900
+ @@ -139,6 +153,8 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
901
+ cmd.Flags().StringVar(&webhookURL, "webhook", "", "URL to POST live message JSON")
902
+ cmd.Flags().StringVar(&webhookSecret, "webhook-secret", "", "HMAC-SHA256 secret for X-Wacli-Signature header")
903
+ cmd.Flags().BoolVar(&webhookAllowPrivate, "webhook-allow-private", false, "allow webhook URLs that resolve to localhost or private networks")
904
+ + cmd.Flags().BoolVar(&ghostgetPrivate, "ghostget-private-transport", false, "use the versioned Ghostget one-attempt private transport instead of the ordinary send socket")
905
+ + cmd.Flags().StringVar(&ghostgetSocket, "ghostget-private-socket", "", "socket in the supervising owner's physical private directory")
906
+ cmd.Flags().Int64Var(&storage.maxMessages, "max-messages", 0, "maximum total messages to keep in the local DB before sync stops (0 = unlimited, or WACLI_SYNC_MAX_MESSAGES)")
907
+ cmd.Flags().StringVar(&storage.maxDBSize, "max-db-size", "", "maximum wacli.db disk usage before sync stops, e.g. 500MB or 2GB (default: WACLI_SYNC_MAX_DB_SIZE or unlimited)")
908
+ return cmd
909
+ diff --git a/internal/store/ghostget_events.go b/internal/store/ghostget_events.go
910
+ new file mode 100644
911
+ index 0000000..53916a3
912
+ --- /dev/null
913
+ +++ b/internal/store/ghostget_events.go
914
+ @@ -0,0 +1,75 @@
915
+ +package store
916
+ +
917
+ +import (
918
+ + "crypto/rand"
919
+ + "encoding/hex"
920
+ + "fmt"
921
+ +)
922
+ +
923
+ +// EnableGhostgetAutomationEvents is used only by the explicitly selected
924
+ +// private transport. SQLite triggers commit each projection change alongside
925
+ +// the message write. The bounded ledger may expire cursors; readers must check
926
+ +// its minimum sequence and exact generation before using an incremental page.
927
+ +func (d *DB) EnableGhostgetAutomationEvents() error {
928
+ + // One writer connection preserves the FULL durability setting for every
929
+ + // message/event transaction in this explicitly selected runtime.
930
+ + d.sql.SetMaxOpenConns(1)
931
+ + if _, err := d.sql.Exec("PRAGMA synchronous=FULL"); err != nil {
932
+ + return err
933
+ + }
934
+ + var nonce [32]byte
935
+ + if _, err := rand.Read(nonce[:]); err != nil {
936
+ + return err
937
+ + }
938
+ + tx, err := d.sql.Begin()
939
+ + if err != nil {
940
+ + return err
941
+ + }
942
+ + defer tx.Rollback()
943
+ + _, err = tx.Exec(`
944
+ +CREATE TABLE IF NOT EXISTS ghostget_automation_state (
945
+ + singleton INTEGER PRIMARY KEY CHECK(singleton=1), version INTEGER NOT NULL CHECK(version=1), generation TEXT NOT NULL
946
+ +);
947
+ +CREATE TABLE IF NOT EXISTS ghostget_automation_events (
948
+ + sequence INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL,
949
+ + chat_jid TEXT NOT NULL, msg_id TEXT NOT NULL, sender_jid TEXT,
950
+ + ts INTEGER NOT NULL, from_me INTEGER NOT NULL, text TEXT, display_text TEXT,
951
+ + quoted_msg_id TEXT, reaction_to_id TEXT, reaction_emoji TEXT,
952
+ + media_type TEXT, filename TEXT, mime_type TEXT, file_length INTEGER,
953
+ + revoked INTEGER NOT NULL, deleted_for_me INTEGER NOT NULL
954
+ +);
955
+ +CREATE INDEX IF NOT EXISTS ghostget_events_scope ON ghostget_automation_events(chat_jid,sequence);
956
+ +`)
957
+ + if err != nil {
958
+ + return err
959
+ + }
960
+ + if _, err = tx.Exec(`INSERT OR IGNORE INTO ghostget_automation_state(singleton,version,generation) VALUES(1,1,?)`, hex.EncodeToString(nonce[:])); err != nil {
961
+ + return err
962
+ + }
963
+ + for _, event := range []struct{ name, op, source, kind string }{
964
+ + {"insert", "INSERT", "NEW", `CASE WHEN NEW.reaction_to_id IS NOT NULL AND NEW.reaction_to_id<>'' THEN 'reaction' ELSE 'message' END`},
965
+ + {"update", "UPDATE", "NEW", `CASE WHEN NEW.revoked=1 OR NEW.deleted_for_me=1 THEN 'delete' WHEN NEW.reaction_to_id IS NOT NULL AND NEW.reaction_to_id<>'' THEN 'reaction' ELSE 'edit' END`},
966
+ + {"delete", "DELETE", "OLD", `'delete'`},
967
+ + } {
968
+ + columns := []string{"chat_jid", "msg_id", "sender_jid", "ts", "from_me", "text", "display_text", "quoted_msg_id", "reaction_to_id", "reaction_emoji", "media_type", "filename", "mime_type", "file_length", "revoked", "deleted_for_me"}
969
+ + values := ""
970
+ + for i, column := range columns {
971
+ + if i > 0 {
972
+ + values += ","
973
+ + }
974
+ + if column == "text" || column == "display_text" {
975
+ + values += fmt.Sprintf("substr(%s.%s,1,65537)", event.source, column)
976
+ + } else {
977
+ + values += event.source + "." + column
978
+ + }
979
+ + }
980
+ + query := fmt.Sprintf(`CREATE TRIGGER IF NOT EXISTS ghostget_events_%s AFTER %s ON messages BEGIN
981
+ + INSERT INTO ghostget_automation_events(kind,chat_jid,msg_id,sender_jid,ts,from_me,text,display_text,quoted_msg_id,reaction_to_id,reaction_emoji,media_type,filename,mime_type,file_length,revoked,deleted_for_me) VALUES(%s,%s);
982
+ + DELETE FROM ghostget_automation_events WHERE sequence <= (SELECT COALESCE(MAX(sequence),0)-10000 FROM ghostget_automation_events);
983
+ + END;`, event.name, event.op, event.kind, values)
984
+ + if _, err = tx.Exec(query); err != nil {
985
+ + return err
986
+ + }
987
+ + }
988
+ + return tx.Commit()
989
+ +}
990
+ diff --git a/internal/store/ghostget_events_test.go b/internal/store/ghostget_events_test.go
991
+ new file mode 100644
992
+ index 0000000..4548138
993
+ --- /dev/null
994
+ +++ b/internal/store/ghostget_events_test.go
995
+ @@ -0,0 +1,95 @@
996
+ +package store
997
+ +
998
+ +import (
999
+ + "fmt"
1000
+ + "testing"
1001
+ + "time"
1002
+ +)
1003
+ +
1004
+ +func TestGhostgetAutomationEventsAtomicAndBounded(t *testing.T) {
1005
+ + db := openTestDB(t)
1006
+ + if err := db.EnableGhostgetAutomationEvents(); err != nil {
1007
+ + t.Fatal(err)
1008
+ + }
1009
+ + var generation string
1010
+ + if err := db.sql.QueryRow(`SELECT generation FROM ghostget_automation_state WHERE singleton=1 AND version=1`).Scan(&generation); err != nil || len(generation) != 64 {
1011
+ + t.Fatal(generation, err)
1012
+ + }
1013
+ + if err := db.EnableGhostgetAutomationEvents(); err != nil {
1014
+ + t.Fatal(err)
1015
+ + }
1016
+ + var again string
1017
+ + db.sql.QueryRow(`SELECT generation FROM ghostget_automation_state`).Scan(&again)
1018
+ + if again != generation {
1019
+ + t.Fatal("restart changed generation")
1020
+ + }
1021
+ + chat := "15550000002@s.whatsapp.net"
1022
+ + if err := db.UpsertChat(chat, "dm", "Synthetic", time.Unix(100, 0)); err != nil {
1023
+ + t.Fatal(err)
1024
+ + }
1025
+ + if err := db.UpsertMessage(UpsertMessageParams{ChatJID: chat, MsgID: "synthetic", Text: "hello", Timestamp: time.Unix(100, 0)}); err != nil {
1026
+ + t.Fatal(err)
1027
+ + }
1028
+ + var kind, text string
1029
+ + if err := db.sql.QueryRow(`SELECT kind,text FROM ghostget_automation_events ORDER BY sequence DESC LIMIT 1`).Scan(&kind, &text); err != nil || kind != "message" || text != "hello" {
1030
+ + t.Fatal(kind, text, err)
1031
+ + }
1032
+ + if err := db.MarkMessageRevoked(chat, "synthetic"); err != nil {
1033
+ + t.Fatal(err)
1034
+ + }
1035
+ + if err := db.sql.QueryRow(`SELECT kind FROM ghostget_automation_events ORDER BY sequence DESC LIMIT 1`).Scan(&kind); err != nil || kind != "delete" {
1036
+ + t.Fatal(kind, err)
1037
+ + }
1038
+ + tx, err := db.sql.Begin()
1039
+ + if err != nil {
1040
+ + t.Fatal(err)
1041
+ + }
1042
+ + if _, err = tx.Exec(`DELETE FROM messages WHERE chat_jid=? AND msg_id=?`, chat, "synthetic"); err != nil {
1043
+ + t.Fatal(err)
1044
+ + }
1045
+ + tx.Rollback()
1046
+ + if countRows(t, db.sql, `SELECT COUNT(*) FROM ghostget_automation_events`) != 2 {
1047
+ + t.Fatal("rolled back mutation leaked event")
1048
+ + }
1049
+ + tx, err = db.sql.Begin()
1050
+ + if err != nil {
1051
+ + t.Fatal(err)
1052
+ + }
1053
+ + for i := 0; i < 10010; i++ {
1054
+ + if _, err = tx.Exec(`INSERT INTO messages(chat_jid,msg_id,ts,from_me) VALUES(?,?,100,0)`, chat, fmt.Sprintf("synthetic-%d", i)); err != nil {
1055
+ + t.Fatal(err)
1056
+ + }
1057
+ + }
1058
+ + if err = tx.Commit(); err != nil {
1059
+ + t.Fatal(err)
1060
+ + }
1061
+ + if countRows(t, db.sql, `SELECT COUNT(*) FROM ghostget_automation_events`) != 10000 {
1062
+ + t.Fatal("event retention exceeded bound")
1063
+ + }
1064
+ + var first, last int64
1065
+ + db.sql.QueryRow(`SELECT MIN(sequence),MAX(sequence) FROM ghostget_automation_events`).Scan(&first, &last)
1066
+ + if first != 13 || last != 10012 {
1067
+ + t.Fatal("cursor gap not explicit", first, last)
1068
+ + }
1069
+ +}
1070
+ +
1071
+ +func TestGhostgetAutomationReactionChangeRetainsEmoji(t *testing.T) {
1072
+ + db := openTestDB(t)
1073
+ + if err := db.EnableGhostgetAutomationEvents(); err != nil {
1074
+ + t.Fatal(err)
1075
+ + }
1076
+ + chat := "15550000002@s.whatsapp.net"
1077
+ + if err := db.UpsertChat(chat, "dm", "Synthetic", time.Unix(100, 0)); err != nil {
1078
+ + t.Fatal(err)
1079
+ + }
1080
+ + if err := db.UpsertMessage(UpsertMessageParams{ChatJID: chat, MsgID: "reaction", ReactionToID: "target", ReactionEmoji: "👍", Timestamp: time.Unix(100, 0)}); err != nil {
1081
+ + t.Fatal(err)
1082
+ + }
1083
+ + if _, err := db.sql.Exec(`UPDATE messages SET reaction_emoji='👎' WHERE chat_jid=? AND msg_id='reaction'`, chat); err != nil {
1084
+ + t.Fatal(err)
1085
+ + }
1086
+ + var kind, emoji string
1087
+ + if err := db.sql.QueryRow(`SELECT kind,reaction_emoji FROM ghostget_automation_events ORDER BY sequence DESC LIMIT 1`).Scan(&kind, &emoji); err != nil || kind != "reaction" || emoji != "👎" {
1088
+ + t.Fatal(kind, emoji, err)
1089
+ + }
1090
+ +}
1091
+ --
1092
+ 2.50.1 (Apple Git-155)
1093
+