hridoy-ultimate 8.0.5 → 9.0.0
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 +26 -0
- package/MERGE_REPORT.md +54 -0
- package/README.md +36 -11
- package/THIRD_PARTY_NOTICES.md +36 -0
- package/docs/TEST_RESULTS.md +57 -59
- package/index.d.ts +64 -4
- package/index.js +6 -0
- package/module/login.js +2 -1
- package/module/loginHelper.js +15 -0
- package/module/options.js +6 -0
- package/package.json +5 -3
- package/src/api/action/logout.js +1 -0
- package/src/api/messaging/setMessageReaction.js +6 -1
- package/src/api/socket/e2ee/index.js +27 -11
- package/src/api/socket/e2ee/native/build/messagix.dll +0 -0
- package/src/api/socket/e2ee/native/build/messagix.so +0 -0
- package/src/api/socket/e2ee/native/lib/index.mjs +1 -1
- package/src/api/socket/listenMqtt.js +13 -0
- package/src/utils/fingerprint.js +16 -7
- package/src/utils/typingIndicator.js +202 -0
- package/test/integration/package.test.js +15 -5
- package/test/integration/typing-wiring.test.js +71 -0
- package/test/unit/fingerprint.test.js +19 -0
- package/test/unit/typing.test.js +136 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 9.0.0 - hridoy-ultimate (merge of 83 analysed builds)
|
|
4
|
+
|
|
5
|
+
Base: hridoy-ultimate 8.0.5 (`fca.zip`), plus fixes taken from the `@eryxenx/fca` 1.0.0-1.1.9,
|
|
6
|
+
`fca-eryxenx` 1.x-39.x lines (see `MERGE_REPORT.md` for the per-fix source versions).
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- **Typing indicator, default ON** (`src/utils/typingIndicator.js`): shows typing when a
|
|
10
|
+
command-like message arrives, clears it when the reply is sent or after 8 s; zero added
|
|
11
|
+
latency; MQTT and E2EE threads. Options `typingIndicator: false | {...}`,
|
|
12
|
+
`api.getTypingIndicator()`, `api.setTypingIndicator()`, `api.withTyping()`.
|
|
13
|
+
- `messagix.so` / `messagix.dll` bundled in `src/api/socket/e2ee/native/build/` (SHA-256 pinned
|
|
14
|
+
in a test and in `THIRD_PARTY_NOTICES.md`). Enabling needs only `FCA_E2EE_NATIVE=1`; the
|
|
15
|
+
engine stays opt-in because it kicks the vendored engine's socket (same E2EE device).
|
|
16
|
+
- `MERGE_REPORT.md`, `THIRD_PARTY_NOTICES.md` (both were listed in `files` but missing).
|
|
17
|
+
- 10 typing tests, fingerprint test, pinned-binary package test.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- `setMessageReaction` no longer falls back to the bogus thread id `"0"`; it rejects with a
|
|
21
|
+
clear error when the thread cannot be resolved.
|
|
22
|
+
- Chrome fingerprint pool refreshed to 149.0.7827.102 / 151.0.7922.75 (was Chrome 131-135).
|
|
23
|
+
The User-Agent is now the reduced `Chrome/<major>.0.0.0` form real Chrome sends; the full
|
|
24
|
+
build stays in `Sec-CH-UA-Full-Version-List` (client hints derived from the same fingerprint).
|
|
25
|
+
- `index.d.ts`: `sendTypingIndicator` had the wrong signature; 23 previously undeclared API methods
|
|
26
|
+
and the `humanize` / `typingIndicator` options are now declared.
|
|
27
|
+
- Logout / stopListening clear typing timers.
|
|
28
|
+
|
|
3
29
|
## 8.0.5 - hridoy-ultimate (first release under this name)
|
|
4
30
|
|
|
5
31
|
Built by merging the eryxenx-fca 1.0.0 - 1.1.9 lineage. Base tree: 1.1.9, with
|
package/MERGE_REPORT.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Merge report - hridoy-ultimate 9.0.0
|
|
2
|
+
|
|
3
|
+
## Inputs
|
|
4
|
+
- 83 published packages, analysed statically (extract, per-version diff, `node --check`, reading
|
|
5
|
+
the core paths): `@eryxenx/fca` 1.0.0-1.1.9 (18), `fca-eryxenx` 1.0.0-29.0.0 (20),
|
|
6
|
+
3.0.2 + 30.0.0-39.1.8 (20), 4.0.x + 39.1.9-39.9.0 (20), 6.0.0-9.0.3 (5).
|
|
7
|
+
- `fca.zip` = hridoy-ultimate 8.0.5, used as the base tree (already a hardened merge of
|
|
8
|
+
`@eryxenx/fca` 1.1.9 with `loginHelper.js` from 1.1.8, with a 36-test suite).
|
|
9
|
+
- `messagix.so` / `messagix.dll` supplied by the maintainer.
|
|
10
|
+
|
|
11
|
+
Nothing was run against Facebook. Not every one of the ~14,000 extracted files was read line by
|
|
12
|
+
line; the 12,144 `.js/.cjs/.mjs` files contain only 352 distinct contents.
|
|
13
|
+
|
|
14
|
+
## What the best builds contributed, and where it stands in 9.0.0
|
|
15
|
+
Verified by reading the 8.0.5 code before touching it.
|
|
16
|
+
|
|
17
|
+
| Improvement | Best source | Status |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| Stable MQTT (backoff, SUBACK, 8 s / 12 s timeouts, stale-client guard) | `fca-eryxenx` 20-23 | already in base (own implementation, tested) |
|
|
20
|
+
| Native client reuse + `koffi.disposable` string free (leak fixes) | 39.2.0 / 39.2.1 | already in base |
|
|
21
|
+
| 15 s `withTimeout` on native calls | 39.1.8 | already in base |
|
|
22
|
+
| `getThreadInfo` E2EE branch | 39.1.6 | already in base |
|
|
23
|
+
| E2EE group routing in react / unsend / edit | 39.2.2 | already in base (`isE2EEGroup`) |
|
|
24
|
+
| Session keeper (cookie + `fb_dtsg` refresh) | 39.2.7 | already in base |
|
|
25
|
+
| `Accept` header navigation vs XHR | 39.2.5 | already in base |
|
|
26
|
+
| Attachment id fallback in `sendMessage` | 7.0.0 | already in base |
|
|
27
|
+
| Native E2EE engine opt-in (not auto-connected) | 39.2.8 | already in base |
|
|
28
|
+
| Logout stops MQTT / E2EE / timers | none (all builds lacked it) | already in base |
|
|
29
|
+
| Typing indicator | none (only the opt-in humanizer had one) | **new in 9.0.0** |
|
|
30
|
+
| Bundled `messagix` binaries | maintainer upload | **restored in 9.0.0, opt-in** |
|
|
31
|
+
| Reduced UA + refreshed Chrome pool | fingerprint.js from 1.1.7, rebuilt | **new in 9.0.0** |
|
|
32
|
+
| `setMessageReaction` thread `"0"` fallback | present in every build | **fixed in 9.0.0** |
|
|
33
|
+
| `index.d.ts` coverage | 23 of 66 methods missing in 1.1.9 | **fixed in 9.0.0** |
|
|
34
|
+
|
|
35
|
+
## Rejected on purpose
|
|
36
|
+
- `autoLogin: true` default and the `minhdong.site` credential host (every `fca-eryxenx` build and
|
|
37
|
+
`@eryxenx/fca` 1.0.0-1.0.5). Credentials and the 2FA secret would go to a third party.
|
|
38
|
+
- Default-on humanizer (silently drops commands inside its cooldown), default-on native engine
|
|
39
|
+
(kicks the vendored E2EE socket), bogus `versionCheck` (1.1.9), inert auto-update.
|
|
40
|
+
- 39.3.0-39.9.0: branch cut from the 39.1.2-39.1.4 tree, loses the 39.2.x fixes, adds
|
|
41
|
+
`[E2EE-DEBUG]` logging of full reaction payloads. 39.1.0-39.1.4 / 39.3.0+ also ship a duplicate
|
|
42
|
+
17.5 MB native copy.
|
|
43
|
+
- `fca-eryxenx` 9.0.0 (syntax error in `core/channel.js:178`), 3.0.2-6.0.0 (`editMessage` requires a
|
|
44
|
+
non-existent `../utils`), 4.0.4's dead "stability patch", the stray `index.js.nexca`, nested
|
|
45
|
+
`e2ee/e2ee/` copies.
|
|
46
|
+
|
|
47
|
+
## Known issues kept (documented, not hidden)
|
|
48
|
+
- `module/login.js` still installs process-wide `unhandledRejection` / `uncaughtException`
|
|
49
|
+
handlers that log and continue; they keep bots alive but can hide a fatal state.
|
|
50
|
+
- `src/api/messaging/sendMessage.js` is loaded and then overridden by `src/api/socket/sendMessage.js`
|
|
51
|
+
(`loginHelper.js`); kept for backward compatibility.
|
|
52
|
+
- Legacy HTTP paths still use the deprecated `request` library (`src/utils/nexca-utils.js`).
|
|
53
|
+
- Native binaries: x86-64 only, stripped, license wording conflicts (see `THIRD_PARTY_NOTICES.md`).
|
|
54
|
+
- Live Facebook behaviour (login, MQTT, send, react, E2EE, typing) is NOT verified here.
|
package/README.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# hridoy-ultimate
|
|
2
2
|
|
|
3
|
-
**Version
|
|
3
|
+
**Version 9.0.0 - Owner/maintainer: HR ID OY**
|
|
4
4
|
|
|
5
5
|
An unofficial Facebook Chat API (FCA) for Node.js Messenger bots, unified from the
|
|
6
|
-
|
|
6
|
+
`@eryxenx/fca` 1.0.0 - 1.1.9 and `fca-eryxenx` 1.x - 39.x lineages (83 published builds
|
|
7
|
+
analysed, see `MERGE_REPORT.md`; itself derived from fca-unofficial) with a hardened
|
|
7
8
|
MQTT lifecycle: bounded exponential backoff, a reconnect budget, clean shutdown and no
|
|
8
9
|
timer left behind after `stopListening()` / `logout()`.
|
|
9
10
|
|
|
@@ -13,11 +14,11 @@ timer left behind after `stopListening()` / `logout()`.
|
|
|
13
14
|
## Verification status (read this first)
|
|
14
15
|
| Area | Status |
|
|
15
16
|
|---|---|
|
|
16
|
-
| Unit tests (backoff, scheduler, parsers, humanizer) | **PASS** (real code, no mocks) |
|
|
17
|
-
| MQTT lifecycle, logout, package surface | **PASS - MOCKED** (fake mqtt/axios; see `test/helpers/stub-modules.js`) |
|
|
17
|
+
| Unit tests (backoff, scheduler, parsers, humanizer, typing indicator, fingerprint) | **PASS** (real code, no mocks) |
|
|
18
|
+
| MQTT lifecycle, logout, typing wiring, package surface (incl. pinned binary hashes) | **PASS - MOCKED** (fake mqtt/axios; see `test/helpers/stub-modules.js`) |
|
|
18
19
|
| TypeScript typings | **PASS** with `tsc` against a consumer sample (Node types stubbed) |
|
|
19
20
|
| Live Facebook login / MQTT / sending / reactions / attachments | **NOT TESTED** (no network or account in the build environment) |
|
|
20
|
-
| E2EE (encrypted chats) | **NOT TESTED** - needs optional deps and
|
|
21
|
+
| E2EE (encrypted chats) and the bundled native binaries | **NOT TESTED** - needs optional deps and a live account |
|
|
21
22
|
| `npm install` from a clean registry | **NOT TESTED** (build sandbox was offline) |
|
|
22
23
|
|
|
23
24
|
Full results: `docs/TEST_RESULTS.md`. Honest limits: [Known limitations](#known-limitations).
|
|
@@ -26,7 +27,7 @@ Full results: `docs/TEST_RESULTS.md`. Honest limits: [Known limitations](#known-
|
|
|
26
27
|
Requires Node.js >= 18.
|
|
27
28
|
```bash
|
|
28
29
|
npm install hridoy-ultimate # from a registry, or:
|
|
29
|
-
npm install ./hridoy-ultimate-
|
|
30
|
+
npm install ./hridoy-ultimate-9.0.0.tgz
|
|
30
31
|
```
|
|
31
32
|
`sqlite3` compiles/downloads a native module; E2EE-related packages are optional.
|
|
32
33
|
|
|
@@ -71,7 +72,7 @@ login({ appState }, { listenEvents: true, autoReconnect: true }, (err, api) => {
|
|
|
71
72
|
## Options
|
|
72
73
|
Login options (`setOptions`): `online`, `selfListen`, `listenEvents`, `updatePresence`,
|
|
73
74
|
`forceLogin`, `autoMarkRead`, `listenTyping`, `autoReconnect`, `emitReady`,
|
|
74
|
-
`selfListenEvent`, `userAgent`, `proxy`, `humanize`.
|
|
75
|
+
`selfListenEvent`, `userAgent`, `proxy`, `humanize`, `typingIndicator`.
|
|
75
76
|
|
|
76
77
|
`api.listenMqtt(callback, options)` options (all optional):
|
|
77
78
|
| option | default | meaning |
|
|
@@ -100,12 +101,35 @@ The attempt counter resets after the first healthy sync response.
|
|
|
100
101
|
- Shutdown: `stopListening(cb)` cancels all timers, ends the client, is idempotent.
|
|
101
102
|
`logout()` additionally disconnects E2EE and clears the dtsg refresh timer.
|
|
102
103
|
|
|
104
|
+
## Typing indicator (default ON)
|
|
105
|
+
Shows "typing..." in a thread the moment a command-like message arrives (GoatBot prefix,
|
|
106
|
+
`/`, `!`, `.`) and clears it when your reply is sent, or after a safety timeout (8 s). It
|
|
107
|
+
adds **no latency**: the event is delivered first, the typing signal is fire-and-forget. Works
|
|
108
|
+
for normal and E2EE threads.
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
login(cookie, { typingIndicator: false }); // off
|
|
112
|
+
login(cookie, { typingIndicator: { onlyCommands: false } }); // type for every text message
|
|
113
|
+
login(cookie, { typingIndicator: { commands: ["#"], maxMs: 6000, groups: true, dms: true } });
|
|
114
|
+
|
|
115
|
+
api.setTypingIndicator(false); // switch at runtime
|
|
116
|
+
await api.withTyping(threadID, async () => { /* slow work */ }); // typing while fn runs
|
|
117
|
+
api.sendTypingIndicator(threadID, true, { duration: 5000 }); // manual control
|
|
118
|
+
```
|
|
119
|
+
A GoatBot `config.enableTypingIndicator: false` also disables it unless you pass the option
|
|
120
|
+
explicitly. When `humanize` is enabled the humanizer shows typing itself and this one stays out of the way.
|
|
121
|
+
|
|
103
122
|
## Opt-in features
|
|
104
123
|
- **Humanizer** (`humanize: true`): delays command replies 2-3 s and *drops* commands inside a
|
|
105
124
|
5.5 s cooldown. Drops are intentional and counted; it is off by default. It contains
|
|
106
125
|
special handling for GoatBot globals.
|
|
107
|
-
- **
|
|
108
|
-
|
|
126
|
+
- **Native E2EE engine** (group E2EE receive/send and E2EE media): the `messagix` binaries are
|
|
127
|
+
bundled in `src/api/socket/e2ee/native/build/` (x86-64 `.so` / `.dll`, SHA-256 pinned in
|
|
128
|
+
`THIRD_PARTY_NOTICES.md`). It is still **off by default**: set `FCA_E2EE_NATIVE=1` to enable it
|
|
129
|
+
(`FCA_E2EE_NATIVE_LIB=/path` overrides the bundled file, e.g. for ARM or macOS). Why off: the
|
|
130
|
+
native and vendored engines register the same E2EE device and kick each other, so enabling it
|
|
131
|
+
causes socket churn. Provenance of the binaries is "supplied by the maintainer, not
|
|
132
|
+
independently verifiable" - see `THIRD_PARTY_NOTICES.md`.
|
|
109
133
|
|
|
110
134
|
## API surface
|
|
111
135
|
Package exports (verified by test): `login`, `default`, `createFcaClient`, `MessengerBot`,
|
|
@@ -134,12 +158,13 @@ Methods attached to `api` after login (one per file in `src/api/`; signatures in
|
|
|
134
158
|
- Subscribe-then-publish ordering and the 8 s / 12 s timeouts follow upstream's changelog
|
|
135
159
|
reasoning and mqtt.js semantics; not confirmed against live servers.
|
|
136
160
|
- Legacy HTTP paths still use the deprecated `request` library (`nexca-utils.js`); porting to axios is future work.
|
|
137
|
-
- `sequelize`+`sqlite3` are hard dependencies (heavy); `index.d.ts` is upstream-derived
|
|
161
|
+
- `sequelize`+`sqlite3` are hard dependencies (heavy); `index.d.ts` is upstream-derived; newer methods are typed loosely (`any`).
|
|
138
162
|
- Vendored E2EE bundle has no license header or source.
|
|
139
163
|
|
|
140
164
|
## Migration from eryxenx-fca 1.x
|
|
141
165
|
- Package name `hridoy-ultimate`; Node >= 18.
|
|
142
166
|
- Humanizer and native E2EE are now **opt-in** (they were default-on in 1.1.x / 1.0.8+).
|
|
167
|
+
- 9.0.0: typing indicator is **on by default** (`typingIndicator: false` to disable); the native binaries are bundled again (still opt-in).
|
|
143
168
|
- `apiServer` has no default; autoUpdate is off.
|
|
144
169
|
- Reconnects are bounded: handle `{type:"stop_listen"}`.
|
|
145
170
|
- `api.listenMqtt(cb, options)` gains an optional second argument.
|
|
@@ -149,6 +174,6 @@ Methods attached to `api` after login (one per file in `src/api/`; signatures in
|
|
|
149
174
|
npm install
|
|
150
175
|
npm test # unit + integration (mocked) tests
|
|
151
176
|
npm run lint # syntax check of every source file
|
|
152
|
-
npm pack # builds hridoy-ultimate-
|
|
177
|
+
npm pack # builds hridoy-ultimate-9.0.0.tgz
|
|
153
178
|
```
|
|
154
179
|
Credits and licenses: `LICENSE`, `THIRD_PARTY_NOTICES.md`. Merge details: `MERGE_REPORT.md`.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
hridoy-ultimate is MIT licensed (see `LICENSE`). It contains or builds on the following.
|
|
4
|
+
|
|
5
|
+
## Code lineage (MIT)
|
|
6
|
+
- `eryxenx-fca` / `@eryxenx/fca` and its contributors (builds 1.x - 39.x were analysed and merged).
|
|
7
|
+
- DongDev and the original `fca-unofficial` lineage (<https://github.com/dongp06>).
|
|
8
|
+
- Optional dependency `fca-unofficial` from `github:VangBanLaNhat/fca-unofficial`.
|
|
9
|
+
|
|
10
|
+
## Bundled native binaries (opt-in, `FCA_E2EE_NATIVE=1`)
|
|
11
|
+
Location: `src/api/socket/e2ee/native/build/`
|
|
12
|
+
|
|
13
|
+
| File | Size (bytes) | Type | SHA-256 |
|
|
14
|
+
|---|---|---|---|
|
|
15
|
+
| `messagix.so` | 17,504,872 | ELF 64-bit x86-64, stripped | `6249f50cc23de0b11a710266a2323c306d44cf46530880e58e0cd1cf4b5bf8d9` |
|
|
16
|
+
| `messagix.dll` | 15,802,368 | PE32+ DLL x86-64, stripped | `f8597ba86c90c39cec19b660e75eba59f2022678eb20866b740cfa321036885e` |
|
|
17
|
+
|
|
18
|
+
- Supplied by the maintainer. They are byte-identical to the copies found in every analysed
|
|
19
|
+
`eryxenx` package (58 `.so` and 45 `.dll` copies, no differing hash). They are stripped, so
|
|
20
|
+
they could **not** be audited here, and no source ships with them.
|
|
21
|
+
- A test (`test/integration/package.test.js`) fails if either hash changes.
|
|
22
|
+
- Platforms: x86-64 Linux and Windows only. macOS and ARM (e.g. Termux) need your own build via
|
|
23
|
+
`FCA_E2EE_NATIVE_LIB=/path/to/messagix`.
|
|
24
|
+
- **License question to resolve before you redistribute:** the analysed packages disagree about the
|
|
25
|
+
upstream. `native/NOTICE.md` describes mautrix-go / mautrix-meta as MPL-2.0, while the
|
|
26
|
+
`vendor/stfca-native/NOTICE.md` that shipped in some builds (39.1.0-39.1.4, 39.3.0-39.9.0) says the
|
|
27
|
+
`messagix` part is AGPL-3.0. AGPL-3.0 carries network-use source-offer obligations. Check the
|
|
28
|
+
upstream repository for the exact build you use. See also `src/api/socket/e2ee/native/NOTICE.md`.
|
|
29
|
+
|
|
30
|
+
## Vendored E2EE bundle
|
|
31
|
+
`src/api/socket/e2ee/vendor/fme/dist/index.cjs` (bundled `fb-messenger-e2ee` client) has no license
|
|
32
|
+
header or source in the analysed packages. Treat its terms as unknown.
|
|
33
|
+
|
|
34
|
+
## Runtime dependencies
|
|
35
|
+
Declared in `package.json`; each keeps its own license (axios, mqtt, ws, sequelize, sqlite3,
|
|
36
|
+
@signalapp/libsignal-client, protobufjs, koffi and others).
|
package/docs/TEST_RESULTS.md
CHANGED
|
@@ -1,70 +1,68 @@
|
|
|
1
1
|
# Test results (actual output)
|
|
2
2
|
|
|
3
3
|
Environment: v22.22.2, Linux 6.18.44-fc-v114, no network, third-party modules stubbed (MOCKED where marked).
|
|
4
|
-
Generated: 2026-10-
|
|
4
|
+
Generated: 2026-10-13T20:03Z
|
|
5
5
|
|
|
6
6
|
## npm test (node --test, spec reporter)
|
|
7
7
|
```
|
|
8
|
-
✔ [mocked] logout stops MQTT, disconnects E2EE and clears every timer (
|
|
9
|
-
✔ [mocked] logout works when no listener was ever started (
|
|
10
|
-
✔ [mocked] a failed logout request is reported, not swallowed, and the session stays usable (
|
|
11
|
-
✔ [mocked] sync queue is published only after every SUBACK (
|
|
12
|
-
✔ [mocked] reconnect after socket close uses backoff, then recovers (
|
|
13
|
-
✔ [mocked] stopListening during a cycle must not be revived by a pending retry (race regression) (
|
|
14
|
-
✔ [mocked] stopListening clears timers, ends the client and is safe to call twice (
|
|
15
|
-
✔ [mocked] late socket 'error' after stopListening does not throw (
|
|
16
|
-
✔ [mocked] calling listen twice replaces the old client (no duplicate connection) (
|
|
17
|
-
✔ [mocked] gives up after maxReconnectAttempts and reports stop_listen (no infinite loop) (
|
|
18
|
-
✔ [mocked] a healthy first sync resets the backoff counter (
|
|
19
|
-
✔ [mocked] humanizer middleware is NOT installed by default, and is when opted in (
|
|
20
|
-
✔ [mocked] emitAuth stops everything and reports account_inactive (
|
|
21
|
-
✔ [mocked] listen(callback, options) applies reconnect options per call (
|
|
22
|
-
✔ package metadata: name, version, owner, entry points exist (1.
|
|
23
|
-
✔ regression: every third-party module the source requires is declared (
|
|
24
|
-
✔ vendored E2EE bundle: its external requires are declared too (
|
|
25
|
-
✔
|
|
26
|
-
✔ every JS file in the package parses (
|
|
27
|
-
✔ [mocked] package loads with consistent exports (
|
|
28
|
-
✔ [mocked] login with empty options reports an error via callback and never throws (
|
|
29
|
-
✔
|
|
30
|
-
✔
|
|
31
|
-
✔ humanizer
|
|
32
|
-
✔
|
|
33
|
-
✔
|
|
34
|
-
✔
|
|
35
|
-
✔
|
|
36
|
-
✔
|
|
37
|
-
✔
|
|
38
|
-
✔
|
|
39
|
-
✔
|
|
40
|
-
✔
|
|
41
|
-
✔
|
|
42
|
-
✔
|
|
43
|
-
✔
|
|
44
|
-
|
|
45
|
-
|
|
8
|
+
✔ [mocked] logout stops MQTT, disconnects E2EE and clears every timer (50.186778ms)
|
|
9
|
+
✔ [mocked] logout works when no listener was ever started (14.258077ms)
|
|
10
|
+
✔ [mocked] a failed logout request is reported, not swallowed, and the session stays usable (10.037149ms)
|
|
11
|
+
✔ [mocked] sync queue is published only after every SUBACK (46.187501ms)
|
|
12
|
+
✔ [mocked] reconnect after socket close uses backoff, then recovers (17.548562ms)
|
|
13
|
+
✔ [mocked] stopListening during a cycle must not be revived by a pending retry (race regression) (16.384888ms)
|
|
14
|
+
✔ [mocked] stopListening clears timers, ends the client and is safe to call twice (21.34972ms)
|
|
15
|
+
✔ [mocked] late socket 'error' after stopListening does not throw (9.571458ms)
|
|
16
|
+
✔ [mocked] calling listen twice replaces the old client (no duplicate connection) (12.14378ms)
|
|
17
|
+
✔ [mocked] gives up after maxReconnectAttempts and reports stop_listen (no infinite loop) (9.182964ms)
|
|
18
|
+
✔ [mocked] a healthy first sync resets the backoff counter (16.256224ms)
|
|
19
|
+
✔ [mocked] humanizer middleware is NOT installed by default, and is when opted in (26.861027ms)
|
|
20
|
+
✔ [mocked] emitAuth stops everything and reports account_inactive (8.374336ms)
|
|
21
|
+
✔ [mocked] listen(callback, options) applies reconnect options per call (12.31406ms)
|
|
22
|
+
✔ package metadata: name, version, owner, entry points exist (1.413356ms)
|
|
23
|
+
✔ regression: every third-party module the source requires is declared (24.539389ms)
|
|
24
|
+
✔ vendored E2EE bundle: its external requires are declared too (2.38059ms)
|
|
25
|
+
✔ only the pinned messagix binaries ship, native stays opt-in, no default third-party credential host (125.254664ms)
|
|
26
|
+
✔ every JS file in the package parses (6235.728346ms)
|
|
27
|
+
✔ [mocked] package loads with consistent exports (23.934551ms)
|
|
28
|
+
✔ [mocked] login with empty options reports an error via callback and never throws (3129.536404ms)
|
|
29
|
+
✔ [mocked] typing middleware is installed by default and shows typing for a command without delaying delivery (44.078959ms)
|
|
30
|
+
✔ [mocked] plain chat does not trigger typing, and stopListening clears pending typing timers (16.606205ms)
|
|
31
|
+
✔ [mocked] typing middleware is NOT installed when the humanizer owns typing, or when no controller exists (24.276443ms)
|
|
32
|
+
✔ fingerprint: UA is the reduced Chrome/<major>.0.0.0 form and the full build only appears in client hints (2.989355ms)
|
|
33
|
+
✔ humanizer: first command accepted with a bounded delay, burst inside cooldown dropped (2.724519ms)
|
|
34
|
+
✔ humanizer: non-command messages pass untouched; per-thread isolation (0.56259ms)
|
|
35
|
+
✔ humanizer: enabled:false never delays or drops (1.317371ms)
|
|
36
|
+
✔ computeBackoff grows exponentially, is bounded and never negative (5.845535ms)
|
|
37
|
+
✔ computeBackoff jitter stays within +/- jitter and clamps to max (0.418652ms)
|
|
38
|
+
✔ computeBackoff tolerates garbage input (0.318463ms)
|
|
39
|
+
✔ scheduleReconnect: single timer, debounce, stop, exhaustion (1.587298ms)
|
|
40
|
+
✔ scheduleReconnect: a timer that fires after stop does nothing (race regression) (0.359697ms)
|
|
41
|
+
✔ maxReconnectAttempts: 0 means unlimited only when explicitly set (1.961505ms)
|
|
42
|
+
✔ clearListenerTimers is idempotent and clears every owned timer (0.47163ms)
|
|
43
|
+
✔ detachClient: a late 'error' after detach must not throw (0.605172ms)
|
|
44
|
+
✔ extractDtsg handles each supported markup shape (2.885756ms)
|
|
45
|
+
✔ extractDtsg: jazoest forms, empty and non-string input (1.591823ms)
|
|
46
|
+
✔ fingerprint: default UA and client hints are mutually consistent (1.64789ms)
|
|
47
|
+
✔ fingerprint: Android UA yields mobile Android hints (no desktop contradiction) (0.50116ms)
|
|
48
|
+
✔ typing: command-like message starts typing; plain chat / own messages / non-message events do not (1.739404ms)
|
|
49
|
+
✔ typing: GoatBot prefix and extra prefixes count as commands; onlyCommands:false types for any text (0.355195ms)
|
|
50
|
+
✔ typing: middleware calls next() synchronously (zero added latency) and then starts typing (1.268156ms)
|
|
51
|
+
✔ typing: sending a reply clears typing after the send resolves (16.890953ms)
|
|
52
|
+
✔ typing: safety timeout stops a typing indicator that never got a reply (1.652981ms)
|
|
53
|
+
✔ typing: repeated starts inside minGapMs do not re-publish; after the gap they do (0.464619ms)
|
|
54
|
+
✔ typing: disabled by option false, and by GoatBot enableTypingIndicator:false unless explicitly overridden (0.321804ms)
|
|
55
|
+
✔ typing: configure(false) clears active timers; stopAll is idempotent and publishes nothing (0.424433ms)
|
|
56
|
+
✔ typing: a throwing / rejecting sendTypingIndicator never breaks the bot (11.013855ms)
|
|
57
|
+
✔ typing: withTyping shows typing around fn and clears it even when fn throws (1.512949ms)
|
|
58
|
+
ℹ tests 50
|
|
59
|
+
ℹ suites 0
|
|
60
|
+
ℹ pass 50
|
|
46
61
|
ℹ fail 0
|
|
47
62
|
ℹ cancelled 0
|
|
48
63
|
ℹ skipped 0
|
|
49
|
-
ℹ
|
|
64
|
+
ℹ todo 0
|
|
50
65
|
```
|
|
51
66
|
|
|
52
|
-
##
|
|
53
|
-
|
|
54
|
-
checked 151 files, 0 with syntax errors
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
## TypeScript consumer check (tsc, Node types stubbed)
|
|
58
|
-
```
|
|
59
|
-
tsc exit 0
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
## Same lifecycle tests against original eryxenx-fca 1.1.9
|
|
63
|
-
```
|
|
64
|
-
# tests 11
|
|
65
|
-
# pass 1
|
|
66
|
-
# fail 10
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
## NOT TESTED
|
|
70
|
-
Live Facebook login/MQTT/sending/reactions/attachments, E2EE, clean-registry npm install.
|
|
67
|
+
## Not verified here
|
|
68
|
+
Live Facebook login, MQTT traffic, sending, reactions, typing indicator on the wire, E2EE and the bundled native binaries (no network, no account).
|
package/index.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
// Type definitions for hridoy-ultimate. Derived from the upstream
|
|
2
|
-
//
|
|
3
|
-
// hridoy/nexca additions (see README "Known limitations").
|
|
1
|
+
// Type definitions for hridoy-ultimate 9.0.0 (author: HR ID OY). Derived from the upstream
|
|
2
|
+
// (dongdev / EryXenX) typings. Newer / internal methods are declared loosely (any) on purpose.
|
|
4
3
|
import type EventEmitter from "events";
|
|
5
4
|
import type { Duplex, Readable, Transform } from "stream";
|
|
6
5
|
|
|
@@ -147,9 +146,39 @@ declare function sendMessage(
|
|
|
147
146
|
// Reactions & Interactions
|
|
148
147
|
setMessageReaction: (reaction: string, messageID: string, threadID: string, callback?: (err?: Error) => void, forceCustomReaction?: boolean) => Promise<void>;
|
|
149
148
|
setMessageReactionMqtt: (reaction: string, messageID: string, threadID: string, callback?: (err?: Error) => void) => Promise<void>;
|
|
150
|
-
sendTypingIndicator: (threadID: string, callback?: (err?: Error) => void) => Promise<void>;
|
|
149
|
+
sendTypingIndicator: (threadID: string | string[], isTyping: boolean, options?: IFCAU_TypingSendOptions | ((err?: Error) => void), callback?: (err?: Error) => void) => Promise<void>;
|
|
151
150
|
sendTypingIndicatorMqtt: (isTyping: boolean, threadID: string, callback?: (err?: Error) => void) => Promise<void>;
|
|
152
151
|
|
|
152
|
+
// Typing indicator controller (hridoy-ultimate 9.0.0)
|
|
153
|
+
getTypingIndicator: () => IFCAU_TypingIndicator;
|
|
154
|
+
setTypingIndicator: (option: boolean | IFCAU_TypingIndicatorOptions) => IFCAU_TypingIndicatorOptions;
|
|
155
|
+
withTyping: <T>(threadID: string, fn: () => T | Promise<T>) => Promise<T>;
|
|
156
|
+
|
|
157
|
+
// Previously undeclared methods (typed loosely on purpose; see README "Known limitations")
|
|
158
|
+
OldMessage: (...args: any[]) => any;
|
|
159
|
+
sendMessageMqtt: (...args: any[]) => any;
|
|
160
|
+
listenE2EE: (callback: (err: Error | null, event: any) => void) => any;
|
|
161
|
+
getMessage: (...args: any[]) => any;
|
|
162
|
+
getUserInfoV2: (...args: any[]) => any;
|
|
163
|
+
searchForThread: (...args: any[]) => any;
|
|
164
|
+
shareContact: (...args: any[]) => any;
|
|
165
|
+
uploadAttachment: (...args: any[]) => any;
|
|
166
|
+
sendBroadcast: (...args: any[]) => any;
|
|
167
|
+
sessionGuard: (...args: any[]) => any;
|
|
168
|
+
refreshFb_dtsg: (...args: any[]) => any;
|
|
169
|
+
httpGet: (...args: any[]) => any;
|
|
170
|
+
httpPost: (...args: any[]) => any;
|
|
171
|
+
postFormData: (...args: any[]) => any;
|
|
172
|
+
addExternalModule: (...args: any[]) => any;
|
|
173
|
+
changeAvatar: (...args: any[]) => any;
|
|
174
|
+
changeBio: (...args: any[]) => any;
|
|
175
|
+
createThemeAI: (...args: any[]) => any;
|
|
176
|
+
getThemePictures: (...args: any[]) => any;
|
|
177
|
+
dismissScrapingWarning: (...args: any[]) => any;
|
|
178
|
+
handleFriendRequest: (...args: any[]) => any;
|
|
179
|
+
setPostReaction: (...args: any[]) => any;
|
|
180
|
+
unfriend: (...args: any[]) => any;
|
|
181
|
+
|
|
153
182
|
// Polls
|
|
154
183
|
createPoll: (title: string, threadID: string, options?: { [item: string]: boolean }, callback?: (err?: Error) => void) => Promise<void>;
|
|
155
184
|
|
|
@@ -749,4 +778,35 @@ declare function sendMessage(
|
|
|
749
778
|
autoMarkRead: boolean;
|
|
750
779
|
proxy: string;
|
|
751
780
|
online: boolean;
|
|
781
|
+
/** Opt-in command anti-spam (reply delay + cooldown). Off by default. */
|
|
782
|
+
humanize: boolean | Record<string, any>;
|
|
783
|
+
/** Typing indicator. Default ON; false disables; object fine-tunes it. */
|
|
784
|
+
typingIndicator: boolean | IFCAU_TypingIndicatorOptions;
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
export type IFCAU_TypingIndicatorOptions = {
|
|
788
|
+
enabled?: boolean;
|
|
789
|
+
/** Only type for command-like messages (GoatBot prefix, "/", "!", "."). Default true. */
|
|
790
|
+
onlyCommands?: boolean;
|
|
791
|
+
/** Extra command prefixes. */
|
|
792
|
+
commands?: string[];
|
|
793
|
+
/** Safety stop in ms when no reply is sent. Default 8000. */
|
|
794
|
+
maxMs?: number;
|
|
795
|
+
/** Do not re-send typing for the same thread inside this window. Default 1500. */
|
|
796
|
+
minGapMs?: number;
|
|
797
|
+
groups?: boolean;
|
|
798
|
+
dms?: boolean;
|
|
752
799
|
};
|
|
800
|
+
|
|
801
|
+
export type IFCAU_TypingSendOptions = { duration?: number; autoStop?: boolean; type?: number };
|
|
802
|
+
|
|
803
|
+
export interface IFCAU_TypingIndicator {
|
|
804
|
+
readonly enabled: boolean;
|
|
805
|
+
options: Required<IFCAU_TypingIndicatorOptions>;
|
|
806
|
+
configure(option: boolean | IFCAU_TypingIndicatorOptions): IFCAU_TypingIndicatorOptions;
|
|
807
|
+
isTyping(threadID: string): boolean;
|
|
808
|
+
start(threadID: string): boolean;
|
|
809
|
+
stop(threadID: string): boolean;
|
|
810
|
+
stopAll(): void;
|
|
811
|
+
withTyping<T>(threadID: string, fn: () => T | Promise<T>): Promise<T>;
|
|
812
|
+
}
|
package/index.js
CHANGED
|
@@ -22,6 +22,12 @@ module.exports.MessengerContext = MessengerContext;
|
|
|
22
22
|
module.exports.attachThreadInfoRealtimeSync = attachThreadInfoRealtimeSync;
|
|
23
23
|
module.exports.applyThreadInfoRealtimeEvent = applyThreadInfoRealtimeEvent;
|
|
24
24
|
|
|
25
|
+
// Typing indicator (default on)
|
|
26
|
+
const typingIndicator = require("./src/utils/typingIndicator");
|
|
27
|
+
module.exports.TypingIndicator = typingIndicator.TypingIndicator;
|
|
28
|
+
module.exports.createTypingIndicator = typingIndicator.createTypingIndicator;
|
|
29
|
+
module.exports.typingIndicator = typingIndicator;
|
|
30
|
+
|
|
25
31
|
// Command anti-spam / humanizer
|
|
26
32
|
module.exports.CommandHumanizer = humanizer.CommandHumanizer;
|
|
27
33
|
module.exports.createCommandHumanizer = humanizer.createCommandHumanizer;
|
package/module/login.js
CHANGED
|
@@ -100,7 +100,8 @@ function login(loginData, options, callback) {
|
|
|
100
100
|
online: true,
|
|
101
101
|
emitReady: false,
|
|
102
102
|
userAgent: getDefaultUserAgent(),
|
|
103
|
-
humanize: undefined
|
|
103
|
+
humanize: undefined,
|
|
104
|
+
typingIndicator: undefined
|
|
104
105
|
};
|
|
105
106
|
setOptions(globalOptions, options);
|
|
106
107
|
let prCallback = null;
|
package/module/loginHelper.js
CHANGED
|
@@ -1355,6 +1355,21 @@ function loginHelper(appState, Cookie, email, password, globalOptions, callback)
|
|
|
1355
1355
|
logger(`sendMessage nexca override failed (non-fatal): ${e && e.message ? e.message : String(e)}`, "warn");
|
|
1356
1356
|
}
|
|
1357
1357
|
|
|
1358
|
+
// Typing indicator (default ON, zero added latency): shows "typing..." as soon as a
|
|
1359
|
+
// command-like message arrives, clears it when the reply goes out. Must exist before
|
|
1360
|
+
// the listenMqtt override below because that is where its middleware is installed.
|
|
1361
|
+
try {
|
|
1362
|
+
const { createTypingIndicator, wrapSendMessage } = require("../src/utils/typingIndicator");
|
|
1363
|
+
const typing = createTypingIndicator(api, ctxMain, ctxMain.globalOptions && ctxMain.globalOptions.typingIndicator, logger);
|
|
1364
|
+
ctxMain._typing = typing;
|
|
1365
|
+
wrapSendMessage(api, typing);
|
|
1366
|
+
api.getTypingIndicator = () => typing;
|
|
1367
|
+
api.setTypingIndicator = (opt) => typing.configure(opt);
|
|
1368
|
+
api.withTyping = (threadID, fn) => typing.withTyping(threadID, fn);
|
|
1369
|
+
} catch (e) {
|
|
1370
|
+
logger(`typing indicator init failed (non-fatal): ${e && e.message ? e.message : String(e)}`, "warn");
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1358
1373
|
// listenMqtt override with nexca version (better MQTT stability)
|
|
1359
1374
|
try {
|
|
1360
1375
|
api.listenMqtt = require("../src/api/socket/listenMqtt")(defaultFuncs, api, ctxMain);
|
package/module/options.js
CHANGED
|
@@ -29,6 +29,12 @@ function setOptions(globalOptions, options) {
|
|
|
29
29
|
globalOptions.humanize = options.humanize;
|
|
30
30
|
break;
|
|
31
31
|
}
|
|
32
|
+
case "typingIndicator": {
|
|
33
|
+
// true/undefined = default (on), false = off, object = fine tuning
|
|
34
|
+
// (see src/utils/typingIndicator.js DEFAULTS)
|
|
35
|
+
globalOptions.typingIndicator = options.typingIndicator;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
32
38
|
case "proxy": {
|
|
33
39
|
if (typeof options.proxy !== "string") {
|
|
34
40
|
delete globalOptions.proxy;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hridoy-ultimate",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Unified Facebook Chat API (FCA) for Messenger bots -
|
|
3
|
+
"version": "9.0.0",
|
|
4
|
+
"description": "Unified Facebook Chat API (FCA) for Messenger bots - hardened MQTT lifecycle, default-on typing indicator, bundled (opt-in) native E2EE engine.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
7
7
|
"exports": {
|
|
@@ -43,7 +43,9 @@
|
|
|
43
43
|
"mqtt",
|
|
44
44
|
"bot",
|
|
45
45
|
"unofficial",
|
|
46
|
-
"hridoy-ultimate"
|
|
46
|
+
"hridoy-ultimate",
|
|
47
|
+
"typing-indicator",
|
|
48
|
+
"e2ee"
|
|
47
49
|
],
|
|
48
50
|
"author": {
|
|
49
51
|
"name": "HR ID OY"
|
package/src/api/action/logout.js
CHANGED
|
@@ -68,6 +68,7 @@ module.exports = function(defaultFuncs, api, ctx) {
|
|
|
68
68
|
ctx.loggedIn = false;
|
|
69
69
|
ctx._stopped = true;
|
|
70
70
|
lifecycle.clearListenerTimers(ctx, true);
|
|
71
|
+
if (ctx._typing && typeof ctx._typing.stopAll === "function") ctx._typing.stopAll();
|
|
71
72
|
const steps = [];
|
|
72
73
|
if (typeof api.stopListening === "function") {
|
|
73
74
|
steps.push(new Promise(function(resolve) { api.stopListening(resolve); }));
|
|
@@ -23,7 +23,7 @@ module.exports = function (defaultFuncs, api, ctx) {
|
|
|
23
23
|
// threadID না থাকলে e2ee bridge এর ম্যাপ থেকে বের করার চেষ্টা, তারপর ctx fallback
|
|
24
24
|
const resolvedThreadID = threadID ||
|
|
25
25
|
(api.e2ee && typeof api.e2ee.getThreadIdForMessage === "function" ? api.e2ee.getThreadIdForMessage(messageID) : null);
|
|
26
|
-
const finalThreadID = resolvedThreadID || ctx.lastThreadID
|
|
26
|
+
const finalThreadID = resolvedThreadID || ctx.lastThreadID; // hridoy-ultimate: no more bogus thread "0" fallback
|
|
27
27
|
|
|
28
28
|
const isE2EEDM = ctx.threadTypes && ctx.threadTypes[String(finalThreadID)] === 'dm' &&
|
|
29
29
|
api.e2ee && typeof api.e2ee.isConnected === "function" && api.e2ee.isConnected();
|
|
@@ -48,6 +48,11 @@ module.exports = function (defaultFuncs, api, ctx) {
|
|
|
48
48
|
if (cb) cb(err);
|
|
49
49
|
return reject(err);
|
|
50
50
|
}
|
|
51
|
+
if (!finalThreadID) {
|
|
52
|
+
const err = new Error("setMessageReaction: threadID is required and could not be resolved from messageID");
|
|
53
|
+
if (cb) cb(err);
|
|
54
|
+
return reject(err);
|
|
55
|
+
}
|
|
51
56
|
if (typeof ctx.wsReqNumber !== "number") ctx.wsReqNumber = 0;
|
|
52
57
|
if (typeof ctx.wsTaskNumber !== "number") ctx.wsTaskNumber = 0;
|
|
53
58
|
const reqID = ++ctx.wsReqNumber;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
const path = require("path");
|
|
20
|
+
const fs = require("fs");
|
|
20
21
|
const logger = require("../../../utils/nexca-logger");
|
|
21
22
|
const nativeMediaBridge = require("./native/nativeMediaBridge");
|
|
22
23
|
const { decodeIncomingMedia } = require("./mediaDecode");
|
|
@@ -26,11 +27,26 @@ const localMediaServer = require("./localMediaServer");
|
|
|
26
27
|
// unsend, markRead) is OPT-IN in hridoy-ultimate. It needs a prebuilt
|
|
27
28
|
// "messagix" shared library (mautrix, MPL-2.0) that this package does NOT
|
|
28
29
|
// bundle because its provenance could not be verified from the supplied
|
|
29
|
-
// archives.
|
|
30
|
-
//
|
|
31
|
-
//
|
|
30
|
+
// archives. hridoy-ultimate 9.0.0 now ships the user-supplied messagix.so /
|
|
31
|
+
// messagix.dll under native/build/ (SHA-256 pinned in THIRD_PARTY_NOTICES.md),
|
|
32
|
+
// but the engine stays OPT-IN: set FCA_E2EE_NATIVE=1 (FCA_E2EE_NATIVE_LIB can
|
|
33
|
+
// still override the bundled path). Upstream 1.0.8 flipped this to default-on;
|
|
34
|
+
// that is rejected here because the native and vendored engines share one E2EE
|
|
35
|
+
// device and kick each other (see the note in connect()).
|
|
36
|
+
function _bundledNativeLib() {
|
|
37
|
+
const base = path.join(__dirname, "native", "build");
|
|
38
|
+
if (process.platform === "win32") return path.join(base, "messagix.dll");
|
|
39
|
+
if (process.platform === "darwin") return path.join(base, "messagix.dylib");
|
|
40
|
+
return path.join(base, "messagix.so");
|
|
41
|
+
}
|
|
42
|
+
function _nativeLibAvailable() {
|
|
43
|
+
try {
|
|
44
|
+
if (process.env.FCA_E2EE_NATIVE_LIB) return fs.existsSync(process.env.FCA_E2EE_NATIVE_LIB);
|
|
45
|
+
return fs.existsSync(_bundledNativeLib());
|
|
46
|
+
} catch (_) { return false; }
|
|
47
|
+
}
|
|
32
48
|
function _nativeMediaEnabled() {
|
|
33
|
-
return process.env.FCA_E2EE_NATIVE === "1" &&
|
|
49
|
+
return process.env.FCA_E2EE_NATIVE === "1" && _nativeLibAvailable();
|
|
34
50
|
}
|
|
35
51
|
|
|
36
52
|
// Simple FIFO-bounded Map.set — evicts the oldest entry once maxSize is
|
|
@@ -388,11 +404,11 @@ class E2EEBridge {
|
|
|
388
404
|
// loop (a fresh Noise handshake every few seconds). Until the two
|
|
389
405
|
// engines can use distinct device ids, the native engine must stay
|
|
390
406
|
// OFF for the vendored socket to stay alive. Opt back in with
|
|
391
|
-
// native engine not enabled (
|
|
407
|
+
// native engine not enabled (set FCA_E2EE_NATIVE=1; bundled messagix binary is used unless FCA_E2EE_NATIVE_LIB overrides it) (group E2EE receive + native media sends) if you
|
|
392
408
|
// accept the resulting socket churn.
|
|
393
409
|
const _nativeEnabled = _nativeMediaEnabled();
|
|
394
410
|
if (!_nativeEnabled) {
|
|
395
|
-
logger.info("E2EE", "[native-group] native engine disabled (native engine not enabled (
|
|
411
|
+
logger.info("E2EE", "[native-group] native engine disabled (native engine not enabled (set FCA_E2EE_NATIVE=1; bundled messagix binary is used unless FCA_E2EE_NATIVE_LIB overrides it)) — vendored socket kept exclusive to avoid server-side device kicks");
|
|
396
412
|
}
|
|
397
413
|
try {
|
|
398
414
|
if (!_nativeEnabled) throw { _skipNative: true };
|
|
@@ -526,7 +542,7 @@ class E2EEBridge {
|
|
|
526
542
|
// Native engine disabled (default) — it shares the same E2EE
|
|
527
543
|
// device id as the vendored socket and triggers server-side
|
|
528
544
|
// device kicks. Group E2EE text send unavailable in this mode.
|
|
529
|
-
throw new Error("E2EE group text send requires the native engine, which is disabled (native engine not enabled (
|
|
545
|
+
throw new Error("E2EE group text send requires the native engine, which is disabled (native engine not enabled (set FCA_E2EE_NATIVE=1; bundled messagix binary is used unless FCA_E2EE_NATIVE_LIB overrides it)) to protect the vendored socket from server-side device kicks.");
|
|
530
546
|
}
|
|
531
547
|
// Vendor's own group Sender-Key encryption is broken (see native-group
|
|
532
548
|
// listener above) — group text sends go through native instead.
|
|
@@ -605,7 +621,7 @@ class E2EEBridge {
|
|
|
605
621
|
let result;
|
|
606
622
|
try {
|
|
607
623
|
if (!_nativeMediaEnabled()) {
|
|
608
|
-
throw new Error("native engine disabled (native engine not enabled (
|
|
624
|
+
throw new Error("native engine disabled (native engine not enabled (set FCA_E2EE_NATIVE=1; bundled messagix binary is used unless FCA_E2EE_NATIVE_LIB overrides it)) — see native-group note");
|
|
609
625
|
}
|
|
610
626
|
result = await nativeMediaBridge.sendMedia(appState, threadId, mediaType, data, mimeType, {
|
|
611
627
|
caption: text || undefined,
|
|
@@ -723,7 +739,7 @@ class E2EEBridge {
|
|
|
723
739
|
const isGroup = this._knownE2EEGroups.has(String(threadId));
|
|
724
740
|
try {
|
|
725
741
|
if (!_nativeMediaEnabled())
|
|
726
|
-
throw new Error("native engine disabled (native engine not enabled (
|
|
742
|
+
throw new Error("native engine disabled (native engine not enabled (set FCA_E2EE_NATIVE=1; bundled messagix binary is used unless FCA_E2EE_NATIVE_LIB overrides it))");
|
|
727
743
|
return await nativeMediaBridge.sendReaction(this.api.getAppState(), threadId, messageId, senderJid, reaction, isGroup);
|
|
728
744
|
} catch (err) {
|
|
729
745
|
if (isGroup) {
|
|
@@ -746,7 +762,7 @@ class E2EEBridge {
|
|
|
746
762
|
const isGroup = this._knownE2EEGroups.has(String(threadId));
|
|
747
763
|
try {
|
|
748
764
|
if (!_nativeMediaEnabled())
|
|
749
|
-
throw new Error("native engine disabled (native engine not enabled (
|
|
765
|
+
throw new Error("native engine disabled (native engine not enabled (set FCA_E2EE_NATIVE=1; bundled messagix binary is used unless FCA_E2EE_NATIVE_LIB overrides it))");
|
|
750
766
|
return await nativeMediaBridge.unsendMessage(this.api.getAppState(), threadId, messageId, isGroup);
|
|
751
767
|
} catch (err) {
|
|
752
768
|
if (isGroup) {
|
|
@@ -764,7 +780,7 @@ class E2EEBridge {
|
|
|
764
780
|
const isGroup = this._knownE2EEGroups.has(String(threadId));
|
|
765
781
|
if (isGroup) {
|
|
766
782
|
if (!_nativeMediaEnabled())
|
|
767
|
-
throw new Error("E2EE group edit requires the native engine, which is disabled (native engine not enabled (
|
|
783
|
+
throw new Error("E2EE group edit requires the native engine, which is disabled (native engine not enabled (set FCA_E2EE_NATIVE=1; bundled messagix binary is used unless FCA_E2EE_NATIVE_LIB overrides it)).");
|
|
768
784
|
// Vendor's group Sender-Key encryption is broken, so route
|
|
769
785
|
// group edits through native (it doesn't need a chatJid — the
|
|
770
786
|
// messageId alone identifies which chat/message to edit).
|
|
Binary file
|
|
Binary file
|
|
@@ -28,7 +28,7 @@ function libPath() {
|
|
|
28
28
|
__name(libPath, "libPath");
|
|
29
29
|
var LIB_FILE = libPath();
|
|
30
30
|
if (!fs.existsSync(LIB_FILE)) {
|
|
31
|
-
throw new Error(`Native library not found at ${LIB_FILE}. Set FCA_E2EE_NATIVE_LIB to a trusted messagix build
|
|
31
|
+
throw new Error(`Native library not found at ${LIB_FILE}. No bundled build exists for this platform/arch (only x86-64 .so/.dll ship). Set FCA_E2EE_NATIVE_LIB to a trusted messagix build.`);
|
|
32
32
|
}
|
|
33
33
|
var lib = koffi.load(LIB_FILE);
|
|
34
34
|
var mk = /* @__PURE__ */ __name((ret, name, args) => lib.func(name, ret, args), "mk");
|
|
@@ -75,6 +75,18 @@ module.exports = function (defaultFuncs, api, ctx, opts) {
|
|
|
75
75
|
}
|
|
76
76
|
api.getHumanizer = () => ctx._humanizer || null;
|
|
77
77
|
|
|
78
|
+
// Typing indicator middleware: shared by MQTT and E2EE events (listenE2EE uses the same
|
|
79
|
+
// ctx._middleware). When the humanizer is enabled it already shows typing during its own
|
|
80
|
+
// delay, so ours stays out of its way.
|
|
81
|
+
if (ctx._typing && !ctx._typingMiddlewareInstalled && !ctx._humanizerMiddlewareInstalled) {
|
|
82
|
+
try {
|
|
83
|
+
middleware.use("typing", ctx._typing.middleware());
|
|
84
|
+
ctx._typingMiddlewareInstalled = true;
|
|
85
|
+
} catch (e) {
|
|
86
|
+
logger(`Typing middleware install failed (non-fatal): ${e && e.message ? e.message : String(e)}`, "warn");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
78
90
|
function installPostGuard() {
|
|
79
91
|
if (ctx._postGuarded) return defaultFuncs.post;
|
|
80
92
|
const rawPost = defaultFuncs.post && defaultFuncs.post.bind(defaultFuncs);
|
|
@@ -350,6 +362,7 @@ module.exports = function (defaultFuncs, api, ctx, opts) {
|
|
|
350
362
|
ctx._stopped = true;
|
|
351
363
|
ctx._reconnectAttempt = 0;
|
|
352
364
|
lifecycle.clearListenerTimers(ctx, false);
|
|
365
|
+
if (ctx._typing && typeof ctx._typing.stopAll === "function") ctx._typing.stopAll();
|
|
353
366
|
|
|
354
367
|
ctx._ending = true;
|
|
355
368
|
unsubAll(() => endQuietly(() => {
|
package/src/utils/fingerprint.js
CHANGED
|
@@ -6,20 +6,24 @@
|
|
|
6
6
|
// claiming the exact same browser build is a trivially detectable pattern.
|
|
7
7
|
// Keep this list current; entries that are far behind Chrome's real release
|
|
8
8
|
// timeline are themselves a signal.
|
|
9
|
+
// Builds taken from Google's published Chrome stable release notes (149.0.7827.102 on
|
|
10
|
+
// 2026-06, 151.0.7922.75 on 2026-08). Chrome's release cadence is about one major per
|
|
11
|
+
// 4 weeks (two weeks from Chrome 153 on), so refresh this list every few months.
|
|
9
12
|
const CHROME_VERSIONS = [
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"133.0.6943.98",
|
|
13
|
-
"134.0.6998.117",
|
|
14
|
-
"135.0.7049.85"
|
|
13
|
+
"149.0.7827.102",
|
|
14
|
+
"151.0.7922.75"
|
|
15
15
|
];
|
|
16
16
|
|
|
17
17
|
function pickChromeVersion() {
|
|
18
18
|
return CHROME_VERSIONS[Math.floor(Math.random() * CHROME_VERSIONS.length)];
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// Real Chrome sends a *reduced* User-Agent ("Chrome/<major>.0.0.0"); the full build only
|
|
22
|
+
// appears in Sec-CH-UA-Full-Version-List. A UA carrying the full build number is itself
|
|
23
|
+
// an anomaly, so the UA is reduced here and the full build is kept in the fingerprint.
|
|
21
24
|
function buildUserAgent(version) {
|
|
22
|
-
|
|
25
|
+
const major = String(version).split(".")[0];
|
|
26
|
+
return `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36`;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
// Chrome's Sec-Ch-Ua "GREASE" brand slot rotates through a small set of
|
|
@@ -60,7 +64,12 @@ function clientHintsFromUA(ua) {
|
|
|
60
64
|
const s = String(ua || getDefaultUserAgent());
|
|
61
65
|
const chromeMatch = s.match(/Chrome\/(\d+)\.(\d+)\.(\d+)\.(\d+)/);
|
|
62
66
|
const major = chromeMatch ? chromeMatch[1] : String(getFingerprint().major);
|
|
63
|
-
|
|
67
|
+
let full = chromeMatch ? chromeMatch[0].split("/")[1] : getFingerprint().version;
|
|
68
|
+
// Reduced UA ("151.0.0.0"): report the real full build for our own fingerprint major.
|
|
69
|
+
if (chromeMatch && chromeMatch[2] === "0" && chromeMatch[3] === "0" && chromeMatch[4] === "0" &&
|
|
70
|
+
String(getFingerprint().major) === major) {
|
|
71
|
+
full = getFingerprint().version;
|
|
72
|
+
}
|
|
64
73
|
const isMobile = /Mobile|Android|iPhone|iPad/i.test(s);
|
|
65
74
|
|
|
66
75
|
let platform = "Windows";
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Typing indicator (hridoy-ultimate 9.0.0).
|
|
5
|
+
*
|
|
6
|
+
* Shows "typing…" in a thread as soon as a command-like message arrives and
|
|
7
|
+
* clears it when the bot sends its reply (or after a safety timeout). It never
|
|
8
|
+
* delays event delivery or the reply itself: next() is called first, the
|
|
9
|
+
* typing signal is fire-and-forget.
|
|
10
|
+
*
|
|
11
|
+
* Works for MQTT threads and E2EE threads (api.sendTypingIndicator routes to
|
|
12
|
+
* api.e2ee.sendTyping for connected E2EE DMs).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { isCommandLike, isNoPrefixAdminCommand } = require("./humanizer");
|
|
16
|
+
|
|
17
|
+
const DEFAULTS = Object.freeze({
|
|
18
|
+
enabled: true,
|
|
19
|
+
onlyCommands: true, // type only when the incoming message looks like a command
|
|
20
|
+
commands: [], // extra prefixes (GoatBot prefix and "/", "!", "." are always used)
|
|
21
|
+
maxMs: 8000, // safety stop when no reply is sent
|
|
22
|
+
minGapMs: 1500, // never re-send "typing" for the same thread inside this window
|
|
23
|
+
groups: true,
|
|
24
|
+
dms: true
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function goatBotConfig() {
|
|
28
|
+
try {
|
|
29
|
+
return typeof global !== "undefined" && global.GoatBot && global.GoatBot.config ? global.GoatBot.config : null;
|
|
30
|
+
} catch (_) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Normalise the user option: undefined/true -> defaults, false -> disabled, object -> merged. */
|
|
36
|
+
function normalizeOptions(opt) {
|
|
37
|
+
if (opt === false) return Object.assign({}, DEFAULTS, { enabled: false });
|
|
38
|
+
const merged = Object.assign({}, DEFAULTS, opt && typeof opt === "object" ? opt : {});
|
|
39
|
+
// A GoatBot config that explicitly turns the feature off wins unless the caller
|
|
40
|
+
// passed an explicit option.
|
|
41
|
+
if (opt === undefined || opt === null) {
|
|
42
|
+
const cfg = goatBotConfig();
|
|
43
|
+
if (cfg && cfg.enableTypingIndicator === false) merged.enabled = false;
|
|
44
|
+
}
|
|
45
|
+
merged.maxMs = Math.max(1000, Number(merged.maxMs) || DEFAULTS.maxMs);
|
|
46
|
+
merged.minGapMs = Math.max(0, Number(merged.minGapMs) || 0);
|
|
47
|
+
merged.commands = Array.isArray(merged.commands) ? merged.commands.filter((p) => typeof p === "string" && p) : [];
|
|
48
|
+
return merged;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
class TypingIndicator {
|
|
52
|
+
constructor(api, ctx, options, log) {
|
|
53
|
+
this.api = api;
|
|
54
|
+
this.ctx = ctx || {};
|
|
55
|
+
this.log = typeof log === "function" ? log : () => {};
|
|
56
|
+
this.options = normalizeOptions(options);
|
|
57
|
+
this.active = new Map(); // threadID -> { timer, startedAt }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
configure(options) {
|
|
61
|
+
this.options = normalizeOptions(options);
|
|
62
|
+
if (!this.options.enabled) this.stopAll();
|
|
63
|
+
return this.options;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get enabled() {
|
|
67
|
+
return this.options.enabled === true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
isTyping(threadID) {
|
|
71
|
+
return this.active.has(String(threadID));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_prefixes() {
|
|
75
|
+
const list = ["/", "!", "."].concat(this.options.commands);
|
|
76
|
+
const cfg = goatBotConfig();
|
|
77
|
+
if (cfg && typeof cfg.prefix === "string" && cfg.prefix) list.push(cfg.prefix);
|
|
78
|
+
return list;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Should this incoming event trigger a typing indicator? */
|
|
82
|
+
wants(event) {
|
|
83
|
+
if (!this.enabled || !event || typeof event !== "object") return false;
|
|
84
|
+
if (event.type !== "message" && event.type !== "message_reply") return false;
|
|
85
|
+
if (!event.threadID || typeof event.body !== "string" || !event.body.trim()) return false;
|
|
86
|
+
if (this.ctx.userID && String(event.senderID) === String(this.ctx.userID)) return false;
|
|
87
|
+
if (event.isGroup === true && !this.options.groups) return false;
|
|
88
|
+
if (event.isGroup === false && !this.options.dms) return false;
|
|
89
|
+
if (!this.options.onlyCommands) return true;
|
|
90
|
+
return isCommandLike(event.body, this._prefixes()) || isNoPrefixAdminCommand(event);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
_publish(threadID, state) {
|
|
94
|
+
const fn = this.api && this.api.sendTypingIndicator;
|
|
95
|
+
if (typeof fn !== "function") return;
|
|
96
|
+
try {
|
|
97
|
+
// autoStop:false -> we own the stop; duration keeps Facebook's own timeout just past ours.
|
|
98
|
+
const r = fn.call(this.api, threadID, state, { autoStop: false, duration: this.options.maxMs + 2000 });
|
|
99
|
+
if (r && typeof r.catch === "function") r.catch(() => {});
|
|
100
|
+
} catch (_) { /* typing is cosmetic: never throw into the bot */ }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
start(threadID) {
|
|
104
|
+
if (!this.enabled || !threadID) return false;
|
|
105
|
+
const tid = String(threadID);
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
const cur = this.active.get(tid);
|
|
108
|
+
if (cur) {
|
|
109
|
+
// Already typing: just push the safety stop out, and do not re-send inside minGapMs.
|
|
110
|
+
clearTimeout(cur.timer);
|
|
111
|
+
cur.timer = this._arm(tid);
|
|
112
|
+
if (now - cur.startedAt < this.options.minGapMs) return false;
|
|
113
|
+
cur.startedAt = now;
|
|
114
|
+
this._publish(tid, true);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
this.active.set(tid, { timer: this._arm(tid), startedAt: now });
|
|
118
|
+
this._publish(tid, true);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
_arm(tid) {
|
|
123
|
+
const t = setTimeout(() => this.stop(tid), this.options.maxMs);
|
|
124
|
+
if (t && typeof t.unref === "function") t.unref();
|
|
125
|
+
return t;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
stop(threadID) {
|
|
129
|
+
if (!threadID) return false;
|
|
130
|
+
const tid = String(threadID);
|
|
131
|
+
const cur = this.active.get(tid);
|
|
132
|
+
if (!cur) return false;
|
|
133
|
+
clearTimeout(cur.timer);
|
|
134
|
+
this.active.delete(tid);
|
|
135
|
+
this._publish(tid, false);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Clear every timer without publishing (used on logout / stopListening: the socket is going away). */
|
|
140
|
+
stopAll() {
|
|
141
|
+
for (const cur of this.active.values()) clearTimeout(cur.timer);
|
|
142
|
+
this.active.clear();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Called by the sendMessage wrapper: clear typing once the message has gone out. */
|
|
146
|
+
noteSend(threadID, result) {
|
|
147
|
+
const ids = Array.isArray(threadID) ? threadID : [threadID];
|
|
148
|
+
const stopAfter = () => ids.forEach((id) => { if (id) this.stop(id); });
|
|
149
|
+
if (!ids.some((id) => id && this.isTyping(id))) return;
|
|
150
|
+
if (result && typeof result.then === "function") result.then(stopAfter, stopAfter);
|
|
151
|
+
else {
|
|
152
|
+
const t = setTimeout(stopAfter, 300);
|
|
153
|
+
if (t && typeof t.unref === "function") t.unref();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Run fn while "typing…" is shown in threadID; always clears afterwards. */
|
|
158
|
+
async withTyping(threadID, fn) {
|
|
159
|
+
this.start(threadID);
|
|
160
|
+
try {
|
|
161
|
+
return await fn();
|
|
162
|
+
} finally {
|
|
163
|
+
this.stop(threadID);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Middleware for ctx._middleware (shared by MQTT and E2EE listeners). */
|
|
168
|
+
middleware() {
|
|
169
|
+
return (event, next) => {
|
|
170
|
+
next(); // never delay event delivery
|
|
171
|
+
try {
|
|
172
|
+
if (this.wants(event)) this.start(event.threadID);
|
|
173
|
+
} catch (_) { /* cosmetic */ }
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Wrap api.sendMessage so a reply clears the typing indicator of its thread. */
|
|
179
|
+
function wrapSendMessage(api, typing) {
|
|
180
|
+
if (!api || typeof api.sendMessage !== "function" || api.sendMessage.__typingWrapped) return false;
|
|
181
|
+
const original = api.sendMessage;
|
|
182
|
+
const wrapped = function (msg, threadID) {
|
|
183
|
+
const result = original.apply(this, arguments);
|
|
184
|
+
try { typing.noteSend(threadID, result); } catch (_) { /* cosmetic */ }
|
|
185
|
+
return result;
|
|
186
|
+
};
|
|
187
|
+
wrapped.__typingWrapped = true;
|
|
188
|
+
api.sendMessage = wrapped;
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function createTypingIndicator(api, ctx, options, log) {
|
|
193
|
+
return new TypingIndicator(api, ctx, options, log);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = {
|
|
197
|
+
TypingIndicator,
|
|
198
|
+
createTypingIndicator,
|
|
199
|
+
wrapSendMessage,
|
|
200
|
+
normalizeOptions,
|
|
201
|
+
DEFAULTS
|
|
202
|
+
};
|
|
@@ -21,7 +21,7 @@ function walk(dir, out = []) {
|
|
|
21
21
|
|
|
22
22
|
test("package metadata: name, version, owner, entry points exist", () => {
|
|
23
23
|
assert.equal(pkg.name, "hridoy-ultimate");
|
|
24
|
-
assert.equal(pkg.version, "
|
|
24
|
+
assert.equal(pkg.version, "9.0.0");
|
|
25
25
|
assert.equal(pkg.author.name, "HR ID OY");
|
|
26
26
|
assert.ok(pkg.maintainers.some((m) => m.name === "HR ID OY"));
|
|
27
27
|
assert.ok(fs.existsSync(path.join(root, pkg.main)));
|
|
@@ -56,15 +56,25 @@ test("vendored E2EE bundle: its external requires are declared too", () => {
|
|
|
56
56
|
for (const n of names) assert.ok(declared.has(n), `${n} used by vendor bundle but undeclared`);
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
test("
|
|
59
|
+
test("only the pinned messagix binaries ship, native stays opt-in, no default third-party credential host", () => {
|
|
60
|
+
const crypto = require("crypto");
|
|
60
61
|
const files = walk(root);
|
|
61
|
-
const bin = files.filter((p) => /\.(so|dll|dylib|node|exe)$/.test(p));
|
|
62
|
-
assert.deepEqual(bin, [
|
|
62
|
+
const bin = files.filter((p) => /\.(so|dll|dylib|node|exe)$/.test(p)).map((p) => path.relative(root, p).split(path.sep).join("/")).sort();
|
|
63
|
+
assert.deepEqual(bin, [
|
|
64
|
+
"src/api/socket/e2ee/native/build/messagix.dll",
|
|
65
|
+
"src/api/socket/e2ee/native/build/messagix.so"
|
|
66
|
+
]);
|
|
67
|
+
const sha = (f) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, f))).digest("hex");
|
|
68
|
+
assert.equal(sha(bin[1]), "6249f50cc23de0b11a710266a2323c306d44cf46530880e58e0cd1cf4b5bf8d9");
|
|
69
|
+
assert.equal(sha(bin[0]), "f8597ba86c90c39cec19b660e75eba59f2022678eb20866b740cfa321036885e");
|
|
70
|
+
const e2ee = fs.readFileSync(path.join(root, "src/api/socket/e2ee/index.js"), "utf8");
|
|
71
|
+
assert.match(e2ee, /process\.env\.FCA_E2EE_NATIVE === "1" && _nativeLibAvailable\(\)/, "native engine must stay opt-in");
|
|
63
72
|
const hits = files.filter((p) => /\.(js|cjs|mjs|json)$/.test(p) && fs.readFileSync(p, "utf8").includes("minhdong.site"));
|
|
64
73
|
assert.deepEqual(hits.map((p) => path.relative(root, p)), []);
|
|
65
74
|
const cfg = fs.readFileSync(path.join(root, "module/config.js"), "utf8");
|
|
66
75
|
assert.match(cfg, /apiServer:\s*""/);
|
|
67
76
|
assert.match(cfg, /autoUpdate:\s*false/);
|
|
77
|
+
assert.match(cfg, /autoLogin:\s*false/);
|
|
68
78
|
});
|
|
69
79
|
|
|
70
80
|
test("every JS file in the package parses", () => {
|
|
@@ -81,7 +91,7 @@ test("[mocked] package loads with consistent exports", () => {
|
|
|
81
91
|
assert.equal(m.default, m.login);
|
|
82
92
|
const expected = ["login", "default", "createFcaClient", "MessengerBot", "createMessengerBot", "MessengerContext",
|
|
83
93
|
"attachThreadInfoRealtimeSync", "applyThreadInfoRealtimeEvent", "CommandHumanizer", "createCommandHumanizer",
|
|
84
|
-
"createHumanizerMiddleware", "humanizer"];
|
|
94
|
+
"createHumanizerMiddleware", "humanizer", "TypingIndicator", "createTypingIndicator", "typingIndicator"];
|
|
85
95
|
assert.deepEqual(Object.keys(m).sort(), expected.sort(), "export surface changed: update README + this list");
|
|
86
96
|
for (const k of expected) assert.notEqual(m[k], undefined, `${k} is undefined`);
|
|
87
97
|
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* MOCKED integration: the typing indicator is wired into the real listenMqtt factory through the
|
|
4
|
+
* shared ctx._middleware (also used by listenE2EE) and is torn down by stopListening().
|
|
5
|
+
*/
|
|
6
|
+
const test = require("node:test");
|
|
7
|
+
const assert = require("node:assert/strict");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const os = require("os");
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const root = process.env.HRIDOY_TEST_ROOT || path.join(__dirname, "..", "..");
|
|
12
|
+
const stubs = require("../helpers/stub-modules");
|
|
13
|
+
stubs.install();
|
|
14
|
+
process.chdir(fs.mkdtempSync(path.join(os.tmpdir(), "hridoy-typing-")));
|
|
15
|
+
|
|
16
|
+
function build({ withTyping = true, globalOptions = {} } = {}) {
|
|
17
|
+
stubs.purge(root);
|
|
18
|
+
stubs.FakeMqttClient.instances.length = 0;
|
|
19
|
+
const { TypingIndicator } = require(path.join(root, "src/utils/typingIndicator"));
|
|
20
|
+
const sent = [];
|
|
21
|
+
const api = {
|
|
22
|
+
getCookies: () => "c_user=1; xs=abc",
|
|
23
|
+
refreshFb_dtsg: () => Promise.resolve(),
|
|
24
|
+
sendTypingIndicator: (tid, state) => { sent.push([tid, state]); return Promise.resolve(); }
|
|
25
|
+
};
|
|
26
|
+
const ctx = {
|
|
27
|
+
userID: "100000000000001", clientId: "abcd1234", loggedIn: true, firstListen: true, lastSeqId: 42,
|
|
28
|
+
globalOptions: Object.assign({ autoReconnect: true, emitReady: true, userAgent: "UA" }, globalOptions),
|
|
29
|
+
tasks: new Map(), _mqttOpt: {}
|
|
30
|
+
};
|
|
31
|
+
if (withTyping) ctx._typing = new TypingIndicator(api, ctx, undefined);
|
|
32
|
+
const defaultFuncs = { get: () => Promise.resolve({ data: "" }), post: () => Promise.reject(new Error("network down (test)")) };
|
|
33
|
+
const factory = require(path.join(root, "src/api/socket/listenMqtt.js"));
|
|
34
|
+
const listen = factory(defaultFuncs, api, ctx);
|
|
35
|
+
const events = [];
|
|
36
|
+
const emitter = listen((err, msg) => events.push({ err, msg }));
|
|
37
|
+
return { ctx, api, sent, events, emitter };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const stopAndWait = (s) => new Promise((r) => s.emitter.stopListening(r));
|
|
41
|
+
const msg = (over) => Object.assign({ type: "message", threadID: "t9", senderID: "42", body: "/ping", isGroup: false }, over);
|
|
42
|
+
|
|
43
|
+
test("[mocked] typing middleware is installed by default and shows typing for a command without delaying delivery", async () => {
|
|
44
|
+
const s = build();
|
|
45
|
+
assert.ok(s.ctx._middleware.list().includes("typing"));
|
|
46
|
+
let delivered = null;
|
|
47
|
+
s.ctx._middleware.process(msg(), (err, ev) => { delivered = ev; });
|
|
48
|
+
assert.ok(delivered && delivered.body === "/ping", "event delivered synchronously");
|
|
49
|
+
assert.deepEqual(s.sent, [["t9", true]]);
|
|
50
|
+
await stopAndWait(s);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("[mocked] plain chat does not trigger typing, and stopListening clears pending typing timers", async () => {
|
|
54
|
+
const s = build();
|
|
55
|
+
s.ctx._middleware.process(msg({ body: "hello everyone" }), () => {});
|
|
56
|
+
assert.deepEqual(s.sent, []);
|
|
57
|
+
s.ctx._middleware.process(msg(), () => {});
|
|
58
|
+
assert.equal(s.ctx._typing.isTyping("t9"), true);
|
|
59
|
+
await stopAndWait(s);
|
|
60
|
+
assert.equal(s.ctx._typing.isTyping("t9"), false, "stopListening tears typing down");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("[mocked] typing middleware is NOT installed when the humanizer owns typing, or when no controller exists", async () => {
|
|
64
|
+
const hz = build({ globalOptions: { humanize: true } });
|
|
65
|
+
assert.ok(!hz.ctx._middleware.list().includes("typing"));
|
|
66
|
+
assert.ok(hz.ctx._middleware.list().includes("humanizer"));
|
|
67
|
+
await stopAndWait(hz);
|
|
68
|
+
const none = build({ withTyping: false });
|
|
69
|
+
assert.ok(!none.ctx._middleware.list().includes("typing"));
|
|
70
|
+
await stopAndWait(none);
|
|
71
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const test = require("node:test");
|
|
3
|
+
const assert = require("node:assert/strict");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const root = process.env.HRIDOY_TEST_ROOT || path.join(__dirname, "..", "..");
|
|
6
|
+
const fp = require(path.join(root, "src/utils/fingerprint"));
|
|
7
|
+
|
|
8
|
+
test("fingerprint: UA is the reduced Chrome/<major>.0.0.0 form and the full build only appears in client hints", () => {
|
|
9
|
+
const ua = fp.getDefaultUserAgent();
|
|
10
|
+
const m = ua.match(/Chrome\/(\d+)\.(\d+)\.(\d+)\.(\d+)/);
|
|
11
|
+
assert.ok(m, ua);
|
|
12
|
+
assert.deepEqual([m[2], m[3], m[4]], ["0", "0", "0"], "UA must not leak the full build");
|
|
13
|
+
const { version, major } = fp.getFingerprint();
|
|
14
|
+
assert.equal(String(major), m[1]);
|
|
15
|
+
const h = fp.clientHintsFromUA(ua);
|
|
16
|
+
assert.ok(h["sec-ch-ua-full-version-list"].includes(`"Google Chrome";v="${version}"`));
|
|
17
|
+
assert.ok(h["sec-ch-ua"].includes(`"Google Chrome";v="${major}"`));
|
|
18
|
+
assert.ok(major >= 149, "pool must not fall back to years-old Chrome builds");
|
|
19
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const test = require("node:test");
|
|
3
|
+
const assert = require("node:assert/strict");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const root = process.env.HRIDOY_TEST_ROOT || path.join(__dirname, "..", "..");
|
|
6
|
+
const { TypingIndicator, wrapSendMessage, normalizeOptions } = require(path.join(root, "src/utils/typingIndicator"));
|
|
7
|
+
|
|
8
|
+
function fakeApi() {
|
|
9
|
+
const calls = [];
|
|
10
|
+
return {
|
|
11
|
+
calls,
|
|
12
|
+
sendTypingIndicator(threadID, state, opts) { calls.push([threadID, state, opts]); return Promise.resolve(); },
|
|
13
|
+
sendMessage(msg, threadID) { return Promise.resolve({ messageID: "m1", threadID }); }
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const cmd = (over) => Object.assign({ type: "message", threadID: "t1", senderID: "u1", body: "/help", isGroup: true }, over);
|
|
17
|
+
|
|
18
|
+
test("typing: command-like message starts typing; plain chat / own messages / non-message events do not", () => {
|
|
19
|
+
const api = fakeApi();
|
|
20
|
+
const t = new TypingIndicator(api, { userID: "bot" }, undefined);
|
|
21
|
+
assert.equal(t.wants(cmd()), true);
|
|
22
|
+
assert.equal(t.wants(cmd({ body: "hello there" })), false);
|
|
23
|
+
assert.equal(t.wants(cmd({ senderID: "bot" })), false);
|
|
24
|
+
assert.equal(t.wants(cmd({ type: "typ" })), false);
|
|
25
|
+
assert.equal(t.wants(cmd({ body: " " })), false);
|
|
26
|
+
t.stopAll();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("typing: GoatBot prefix and extra prefixes count as commands; onlyCommands:false types for any text", () => {
|
|
30
|
+
global.GoatBot = { config: { prefix: "+" } };
|
|
31
|
+
try {
|
|
32
|
+
const t = new TypingIndicator(fakeApi(), {}, { commands: ["#"] });
|
|
33
|
+
assert.equal(t.wants(cmd({ body: "+ping" })), true);
|
|
34
|
+
assert.equal(t.wants(cmd({ body: "#x" })), true);
|
|
35
|
+
const all = new TypingIndicator(fakeApi(), {}, { onlyCommands: false });
|
|
36
|
+
assert.equal(all.wants(cmd({ body: "just chatting" })), true);
|
|
37
|
+
} finally { delete global.GoatBot; }
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("typing: middleware calls next() synchronously (zero added latency) and then starts typing", () => {
|
|
41
|
+
const api = fakeApi();
|
|
42
|
+
const t = new TypingIndicator(api, {}, undefined);
|
|
43
|
+
const order = [];
|
|
44
|
+
const origStart = t.start.bind(t);
|
|
45
|
+
t.start = (tid) => { order.push("start"); return origStart(tid); };
|
|
46
|
+
t.middleware()(cmd(), () => order.push("next"));
|
|
47
|
+
assert.deepEqual(order, ["next", "start"]);
|
|
48
|
+
assert.equal(api.calls.length, 1);
|
|
49
|
+
assert.deepEqual(api.calls[0].slice(0, 2), ["t1", true]);
|
|
50
|
+
assert.equal(api.calls[0][2].autoStop, false);
|
|
51
|
+
t.stopAll();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("typing: sending a reply clears typing after the send resolves", async () => {
|
|
55
|
+
const api = fakeApi();
|
|
56
|
+
const t = new TypingIndicator(api, {}, undefined);
|
|
57
|
+
assert.equal(wrapSendMessage(api, t), true);
|
|
58
|
+
assert.equal(wrapSendMessage(api, t), false, "wrapping is idempotent");
|
|
59
|
+
t.start("t1");
|
|
60
|
+
const res = await api.sendMessage("hi", "t1");
|
|
61
|
+
assert.equal(res.messageID, "m1", "result passes through unchanged");
|
|
62
|
+
await new Promise((r) => setImmediate(r));
|
|
63
|
+
assert.equal(t.isTyping("t1"), false);
|
|
64
|
+
assert.deepEqual(api.calls.map((c) => c[1]), [true, false]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("typing: safety timeout stops a typing indicator that never got a reply", (ctx) => {
|
|
68
|
+
ctx.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 10_000 });
|
|
69
|
+
const api = fakeApi();
|
|
70
|
+
const t = new TypingIndicator(api, {}, { maxMs: 3000 });
|
|
71
|
+
t.start("t1");
|
|
72
|
+
assert.equal(t.isTyping("t1"), true);
|
|
73
|
+
ctx.mock.timers.tick(2999);
|
|
74
|
+
assert.equal(t.isTyping("t1"), true);
|
|
75
|
+
ctx.mock.timers.tick(2);
|
|
76
|
+
assert.equal(t.isTyping("t1"), false);
|
|
77
|
+
assert.deepEqual(api.calls.map((c) => c[1]), [true, false]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("typing: repeated starts inside minGapMs do not re-publish; after the gap they do", (ctx) => {
|
|
81
|
+
ctx.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 50_000 });
|
|
82
|
+
const api = fakeApi();
|
|
83
|
+
const t = new TypingIndicator(api, {}, { minGapMs: 1500, maxMs: 8000 });
|
|
84
|
+
assert.equal(t.start("t1"), true);
|
|
85
|
+
ctx.mock.timers.tick(500);
|
|
86
|
+
assert.equal(t.start("t1"), false);
|
|
87
|
+
ctx.mock.timers.tick(1500);
|
|
88
|
+
assert.equal(t.start("t1"), true);
|
|
89
|
+
assert.equal(api.calls.filter((c) => c[1] === true).length, 2);
|
|
90
|
+
t.stopAll();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("typing: disabled by option false, and by GoatBot enableTypingIndicator:false unless explicitly overridden", () => {
|
|
94
|
+
assert.equal(normalizeOptions(false).enabled, false);
|
|
95
|
+
assert.equal(normalizeOptions(undefined).enabled, true, "default is ON");
|
|
96
|
+
global.GoatBot = { config: { enableTypingIndicator: false } };
|
|
97
|
+
try {
|
|
98
|
+
assert.equal(normalizeOptions(undefined).enabled, false);
|
|
99
|
+
assert.equal(normalizeOptions(true).enabled, true, "explicit option wins");
|
|
100
|
+
} finally { delete global.GoatBot; }
|
|
101
|
+
const api = fakeApi();
|
|
102
|
+
const t = new TypingIndicator(api, {}, false);
|
|
103
|
+
assert.equal(t.start("t1"), false);
|
|
104
|
+
assert.equal(api.calls.length, 0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("typing: configure(false) clears active timers; stopAll is idempotent and publishes nothing", () => {
|
|
108
|
+
const api = fakeApi();
|
|
109
|
+
const t = new TypingIndicator(api, {}, undefined);
|
|
110
|
+
t.start("a"); t.start("b");
|
|
111
|
+
assert.equal(api.calls.length, 2);
|
|
112
|
+
t.configure(false);
|
|
113
|
+
t.stopAll();
|
|
114
|
+
assert.equal(t.isTyping("a") || t.isTyping("b"), false);
|
|
115
|
+
assert.equal(api.calls.length, 2, "no 'typing off' sent when tearing down");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("typing: a throwing / rejecting sendTypingIndicator never breaks the bot", async () => {
|
|
119
|
+
const bad = { sendTypingIndicator() { throw new Error("boom"); } };
|
|
120
|
+
const rej = { sendTypingIndicator() { return Promise.reject(new Error("nope")); } };
|
|
121
|
+
const t1 = new TypingIndicator(bad, {}, undefined);
|
|
122
|
+
const t2 = new TypingIndicator(rej, {}, undefined);
|
|
123
|
+
assert.doesNotThrow(() => t1.middleware()(cmd(), () => {}));
|
|
124
|
+
assert.doesNotThrow(() => t2.middleware()(cmd(), () => {}));
|
|
125
|
+
await new Promise((r) => setImmediate(r));
|
|
126
|
+
t1.stopAll(); t2.stopAll();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("typing: withTyping shows typing around fn and clears it even when fn throws", async () => {
|
|
130
|
+
const api = fakeApi();
|
|
131
|
+
const t = new TypingIndicator(api, {}, undefined);
|
|
132
|
+
assert.equal(await t.withTyping("t1", async () => 42), 42);
|
|
133
|
+
await assert.rejects(() => t.withTyping("t2", async () => { throw new Error("x"); }), /x/);
|
|
134
|
+
assert.equal(t.isTyping("t1") || t.isTyping("t2"), false);
|
|
135
|
+
assert.deepEqual(api.calls.map((c) => c[1]), [true, false, true, false]);
|
|
136
|
+
});
|