@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.
- package/CHANGELOG.md +283 -0
- package/README.md +7 -7
- package/dist/apple-photos-client.js +2 -1
- package/dist/beeper-client.js +2 -1
- package/dist/client.js +1 -0
- package/dist/imessage-direct-install-2gm3kge7.js +324 -0
- package/dist/index-01eeae9e.js +5 -0
- package/dist/index-2ymnp8xv.js +145 -0
- package/dist/index-en5hycxp.js +175 -0
- package/dist/{index-9wca02er.js → index-hfbygww8.js} +1 -1
- package/dist/index-n4szk3nw.js +50 -0
- package/dist/index-yq6maz71.js +1105 -0
- package/dist/index-z1w83f81.js +4 -0
- package/dist/index.js +4 -47
- package/dist/messaging-automation-api.js +21 -0
- package/dist/messaging-automation-j4274hvc.js +609 -0
- package/dist/messaging-native-install-8w1j36ah.js +16 -0
- package/dist/messaging.js +1 -0
- package/dist/omni-client.js +1 -0
- package/dist/whatsapp-automation-runtime-hgh1e9rm.js +845 -0
- package/dist/whatsapp-client.js +1 -0
- package/docs/control-panel.md +2 -2
- package/docs/imessage-direct-provider.md +31 -7
- package/docs/messaging-automation.md +103 -0
- package/package.json +22 -2
- package/skills/ghostget/references/control-panel.md +1 -1
- package/skills/ghostget/references/install.md +5 -5
- package/skills/ghostget/references/linkedin-adapter.md +209 -13
- package/skills/ghostget/references/platform-patterns.md +1 -1
- package/src/args.ts +18 -5
- package/src/assets/adapters/imessage/wrench-web-adapter.json +126 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.json +1 -1
- package/src/assets/adapters/whatsapp/wrench-web-adapter.json +144 -0
- package/src/assets/messaging-runtime/NOTICE.txt +2580 -0
- package/src/assets/messaging-runtime/imsg-darwin-arm64.gz +0 -0
- package/src/assets/messaging-runtime/phone-number-info.plist.gz +0 -0
- package/src/assets/messaging-runtime/phone-number-metadata.json.gz +0 -0
- package/src/assets/messaging-runtime/phone-number-privacy.plist.gz +0 -0
- package/src/assets/messaging-runtime/wacli-darwin-arm64.gz +0 -0
- package/src/beeper-client-types.ts +1 -1
- package/src/ghostget.ts +16 -4
- package/src/messaging-automation-api.ts +12 -0
- package/src/messaging-automation-descriptors.ts +29 -0
- package/src/messaging-automation-factory.ts +148 -0
- package/src/messaging-automation-server.ts +183 -0
- package/src/messaging-automation-types.ts +155 -0
- package/src/messaging-automation-validation.ts +110 -0
- package/src/messaging-automation.ts +356 -0
- package/src/plugins/imessage-direct/plugin.ts +12 -1
- package/src/plugins/imessage-direct/vendor/0003-feat-rpc-add-no-fetch-rich-cards.patch +276 -0
- package/src/plugins/imessage-direct/vendor/provenance.json +84 -10
- package/src/plugins/linkedin-web/plugin.ts +1 -1
- package/src/plugins/whatsapp-linked-device/plugin.ts +7 -2
- package/src/plugins/whatsapp-linked-device/vendor/0001-ghostget-private-messaging.patch +1093 -0
- package/src/plugins/whatsapp-linked-device/vendor/README.md +39 -0
- package/src/plugins/whatsapp-linked-device/vendor/provenance.json +45 -0
- package/src/provider-plugin.ts +14 -0
- package/src/providers/imessage-automation.ts +250 -0
- package/src/providers/imessage-direct-install.ts +7 -0
- package/src/providers/imessage-direct-runtime.ts +32 -2
- package/src/providers/imessage-direct.ts +13 -3
- package/src/providers/linkedin-contact-failure.ts +21 -0
- package/src/providers/linkedin-contact-platform.ts +8 -1
- package/src/providers/linkedin-contact-program.ts +34 -5
- package/src/providers/linkedin-web-contact.ts +1023 -51
- package/src/providers/linkedin-web-profile-browser.ts +659 -45
- package/src/providers/linkedin-web-runtime.ts +1 -0
- package/src/providers/linkedin-web.ts +46 -2
- package/src/providers/messaging-native-artifacts.ts +38 -0
- package/src/providers/messaging-native-install.ts +75 -0
- package/src/providers/whatsapp-automation-runtime.ts +240 -0
- package/src/providers/whatsapp-automation.ts +153 -0
- package/src/usage.ts +3 -1
- package/src/version.ts +1 -1
- package/src/web-session-contract-definitions.ts +1 -1
- 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(¤t)
|
|
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
|
+
|