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 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
@@ -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 8.0.5 - Owner/maintainer: HR ID OY**
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
- `eryxenx-fca` 1.0.0 - 1.1.9 lineage (itself derived from fca-unofficial) with a hardened
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 an unbundled native library |
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-8.0.5.tgz
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
- - **Optional native E2EE engine**: set `FCA_E2EE_NATIVE=1` and `FCA_E2EE_NATIVE_LIB=/path/to/messagix`.
108
- The library is **not bundled** (provenance unverifiable). Without it, group E2EE and E2EE media are unavailable.
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 and not exhaustive.
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-8.0.5.tgz
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).
@@ -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-13T00:50Z
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 (34.815074ms)
9
- ✔ [mocked] logout works when no listener was ever started (10.349818ms)
10
- ✔ [mocked] a failed logout request is reported, not swallowed, and the session stays usable (7.042588ms)
11
- ✔ [mocked] sync queue is published only after every SUBACK (35.992484ms)
12
- ✔ [mocked] reconnect after socket close uses backoff, then recovers (12.978848ms)
13
- ✔ [mocked] stopListening during a cycle must not be revived by a pending retry (race regression) (10.298466ms)
14
- ✔ [mocked] stopListening clears timers, ends the client and is safe to call twice (10.426547ms)
15
- ✔ [mocked] late socket 'error' after stopListening does not throw (6.131105ms)
16
- ✔ [mocked] calling listen twice replaces the old client (no duplicate connection) (8.659963ms)
17
- ✔ [mocked] gives up after maxReconnectAttempts and reports stop_listen (no infinite loop) (7.160329ms)
18
- ✔ [mocked] a healthy first sync resets the backoff counter (7.857639ms)
19
- ✔ [mocked] humanizer middleware is NOT installed by default, and is when opted in (14.187447ms)
20
- ✔ [mocked] emitAuth stops everything and reports account_inactive (7.957109ms)
21
- ✔ [mocked] listen(callback, options) applies reconnect options per call (6.735507ms)
22
- ✔ package metadata: name, version, owner, entry points exist (1.135404ms)
23
- ✔ regression: every third-party module the source requires is declared (14.850827ms)
24
- ✔ vendored E2EE bundle: its external requires are declared too (1.810192ms)
25
- ✔ no prebuilt native binaries or default third-party credential host ship in the package (5.439855ms)
26
- ✔ every JS file in the package parses (4215.825382ms)
27
- ✔ [mocked] package loads with consistent exports (19.12184ms)
28
- ✔ [mocked] login with empty options reports an error via callback and never throws (3230.691403ms)
29
- ✔ humanizer: first command accepted with a bounded delay, burst inside cooldown dropped (1.957104ms)
30
- ✔ humanizer: non-command messages pass untouched; per-thread isolation (0.413424ms)
31
- ✔ humanizer: enabled:false never delays or drops (0.980328ms)
32
- ✔ computeBackoff grows exponentially, is bounded and never negative (1.903627ms)
33
- ✔ computeBackoff jitter stays within +/- jitter and clamps to max (0.181485ms)
34
- ✔ computeBackoff tolerates garbage input (0.17167ms)
35
- ✔ scheduleReconnect: single timer, debounce, stop, exhaustion (0.820579ms)
36
- ✔ scheduleReconnect: a timer that fires after stop does nothing (race regression) (0.217074ms)
37
- ✔ maxReconnectAttempts: 0 means unlimited only when explicitly set (1.317064ms)
38
- ✔ clearListenerTimers is idempotent and clears every owned timer (0.41435ms)
39
- ✔ detachClient: a late 'error' after detach must not throw (0.347064ms)
40
- ✔ extractDtsg handles each supported markup shape (1.363764ms)
41
- ✔ extractDtsg: jazoest forms, empty and non-string input (0.821093ms)
42
- ✔ fingerprint: default UA and client hints are mutually consistent (0.728798ms)
43
- ✔ fingerprint: Android UA yields mobile Android hints (no desktop contradiction) (0.272025ms)
44
- ℹ tests 36
45
- ℹ pass 36
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
- ℹ duration_ms 15939.95031
64
+ ℹ todo 0
50
65
  ```
51
66
 
52
- ## npm run lint
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 (dongdev / EryXenX)
2
- // typings; they cover the classic FCA surface and are NOT exhaustive for
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;
@@ -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": "8.0.5",
4
- "description": "Unified Facebook Chat API (FCA) for Messenger bots - with a hardened MQTT lifecycle (bounded backoff, clean shutdown) and opt-in anti-spam/E2EE extras.",
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"
@@ -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 || "0";
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. Enable with FCA_E2EE_NATIVE=1 and point FCA_E2EE_NATIVE_LIB at a
30
- // binary you built/trust. Upstream 1.0.8 had flipped this to default-on; that
31
- // is rejected here (see MERGE_REPORT.md).
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" && !!process.env.FCA_E2EE_NATIVE_LIB;
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 (needs FCA_E2EE_NATIVE=1 + FCA_E2EE_NATIVE_LIB) (group E2EE receive + native media sends) if you
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 (needs FCA_E2EE_NATIVE=1 + FCA_E2EE_NATIVE_LIB)) — vendored socket kept exclusive to avoid server-side device kicks");
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 (needs FCA_E2EE_NATIVE=1 + FCA_E2EE_NATIVE_LIB)) to protect the vendored socket from server-side device kicks.");
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 (needs FCA_E2EE_NATIVE=1 + FCA_E2EE_NATIVE_LIB)) — see native-group note");
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 (needs FCA_E2EE_NATIVE=1 + FCA_E2EE_NATIVE_LIB))");
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 (needs FCA_E2EE_NATIVE=1 + FCA_E2EE_NATIVE_LIB))");
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 (needs FCA_E2EE_NATIVE=1 + FCA_E2EE_NATIVE_LIB)).");
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).
@@ -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 for this platform.`);
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(() => {
@@ -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
- "131.0.6778.140",
11
- "132.0.6834.111",
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
- return `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${version} Safari/537.36`;
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
- const full = chromeMatch ? chromeMatch[0].split("/")[1] : getFingerprint().version;
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, "8.0.5");
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("no prebuilt native binaries or default third-party credential host ship in the package", () => {
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
+ });