fb-messenger-e2ee 0.1.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/DOCS.md +310 -0
- package/LICENSE +660 -0
- package/README.md +153 -0
- package/bun.lock +1014 -0
- package/examples/basic-usage.ts +52 -0
- package/jest.config.ts +18 -0
- package/package.json +56 -0
- package/src/config/env.ts +15 -0
- package/src/controllers/client.controller.ts +805 -0
- package/src/controllers/dgw-handler.ts +171 -0
- package/src/controllers/e2ee-handler.ts +832 -0
- package/src/controllers/event-mapper.ts +327 -0
- package/src/core/client.ts +151 -0
- package/src/e2ee/application/e2ee-client.ts +376 -0
- package/src/e2ee/application/fanout-planner.ts +79 -0
- package/src/e2ee/application/outbound-message-cache.ts +58 -0
- package/src/e2ee/application/prekey-maintenance.ts +55 -0
- package/src/e2ee/application/retry-manager.ts +156 -0
- package/src/e2ee/facebook/facebook-protocol-utils.ts +230 -0
- package/src/e2ee/facebook/icdc-payload.ts +31 -0
- package/src/e2ee/index.ts +76 -0
- package/src/e2ee/internal.ts +27 -0
- package/src/e2ee/media/media-crypto.ts +147 -0
- package/src/e2ee/media/media-upload.ts +90 -0
- package/src/e2ee/message/builders/client-payload.ts +54 -0
- package/src/e2ee/message/builders/consumer-application.ts +362 -0
- package/src/e2ee/message/builders/message-application.ts +64 -0
- package/src/e2ee/message/builders/message-transport.ts +84 -0
- package/src/e2ee/message/codecs/protobuf-codecs.ts +101 -0
- package/src/e2ee/message/constants.ts +4 -0
- package/src/e2ee/message/message-builder.ts +15 -0
- package/src/e2ee/message/proto/ArmadilloApplication.proto +281 -0
- package/src/e2ee/message/proto/ArmadilloICDC.proto +14 -0
- package/src/e2ee/message/proto/ConsumerApplication.proto +232 -0
- package/src/e2ee/message/proto/MessageApplication.proto +82 -0
- package/src/e2ee/message/proto/MessageTransport.proto +77 -0
- package/src/e2ee/message/proto/WACommon.proto +66 -0
- package/src/e2ee/message/proto/WAMediaTransport.proto +176 -0
- package/src/e2ee/message/proto/proto-writer.ts +76 -0
- package/src/e2ee/signal/prekey-manager.ts +140 -0
- package/src/e2ee/signal/signal-manager.ts +365 -0
- package/src/e2ee/store/device-json.ts +47 -0
- package/src/e2ee/store/device-repository.ts +12 -0
- package/src/e2ee/store/device-store.ts +538 -0
- package/src/e2ee/transport/binary/decoder.ts +180 -0
- package/src/e2ee/transport/binary/encoder.ts +143 -0
- package/src/e2ee/transport/binary/stanzas.ts +97 -0
- package/src/e2ee/transport/binary/tokens.ts +64 -0
- package/src/e2ee/transport/binary/wa-binary.ts +8 -0
- package/src/e2ee/transport/dgw/dgw-socket.ts +345 -0
- package/src/e2ee/transport/noise/noise-handshake.ts +417 -0
- package/src/e2ee/transport/noise/noise-socket.ts +230 -0
- package/src/index.ts +34 -0
- package/src/models/client.ts +26 -0
- package/src/models/config.ts +14 -0
- package/src/models/domain.ts +299 -0
- package/src/models/e2ee.ts +295 -0
- package/src/models/media.ts +41 -0
- package/src/models/messaging.ts +88 -0
- package/src/models/thread.ts +69 -0
- package/src/repositories/session.repository.ts +20 -0
- package/src/services/auth.service.ts +55 -0
- package/src/services/e2ee.service.ts +174 -0
- package/src/services/facebook-gateway.service.ts +245 -0
- package/src/services/icdc.service.ts +177 -0
- package/src/services/media.service.ts +304 -0
- package/src/services/messaging.service.ts +28 -0
- package/src/services/thread.service.ts +199 -0
- package/src/types/advanced-types.ts +61 -0
- package/src/types/fca-unofficial.d.ts +212 -0
- package/src/types/protocol-types.ts +43 -0
- package/src/utils/fca-utils.ts +30 -0
- package/src/utils/logger.ts +24 -0
- package/src/utils/mime.ts +51 -0
- package/tests/.env.example +87 -0
- package/tests/data/1x1.png +0 -0
- package/tests/data/example.txt +1 -0
- package/tests/data/file_example.mp3 +0 -0
- package/tests/data/file_example.mp4 +0 -0
- package/tests/integration.test.ts +498 -0
- package/tests/script/echo-e2ee.ts +174 -0
- package/tests/script/send-e2ee-media.ts +227 -0
- package/tests/script/send-e2ee-reaction.ts +108 -0
- package/tests/script/send-e2ee-unsend.ts +115 -0
- package/tests/script/send-typing.ts +107 -0
- package/tests/script/test-group-send.ts +105 -0
- package/tests/setup.ts +3 -0
- package/tests/types.test.ts +57 -0
- package/tests/unit/controllers/dgw-handler.test.ts +60 -0
- package/tests/unit/controllers/e2ee-handler.test.ts +293 -0
- package/tests/unit/controllers/event-mapper.test.ts +252 -0
- package/tests/unit/e2ee/application-helpers.test.ts +418 -0
- package/tests/unit/e2ee/device-store.test.ts +126 -0
- package/tests/unit/e2ee/facebook-protocol-utils.test.ts +134 -0
- package/tests/unit/e2ee/media-crypto.test.ts +55 -0
- package/tests/unit/e2ee/media-upload.test.ts +60 -0
- package/tests/unit/e2ee/message-builder.test.ts +42 -0
- package/tests/unit/e2ee/message-builders.test.ts +230 -0
- package/tests/unit/e2ee/noise-handshake.test.ts +260 -0
- package/tests/unit/e2ee/prekey-manager.test.ts +55 -0
- package/tests/unit/e2ee/wa-binary.test.ts +127 -0
- package/tests/unit/services/e2ee.service.test.ts +101 -0
- package/tests/unit/services/facebook-gateway.service.test.ts +138 -0
- package/tests/unit/services/media.service.test.ts +169 -0
- package/tests/unit/utils/fca-utils.test.ts +48 -0
- package/tsconfig.json +19 -0
package/DOCS.md
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# FME - FB Messenger E2EE Documentation
|
|
2
|
+
|
|
3
|
+
This document provides API reference and operational notes for the `FME - FB Messenger E2EE` library.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Core: `FBClient`
|
|
8
|
+
|
|
9
|
+
`FBClient` is the main public entry point.
|
|
10
|
+
|
|
11
|
+
### Constructor
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
new FBClient(options: ClientOptions)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
**ClientOptions**
|
|
18
|
+
|
|
19
|
+
| Option | Type | Description |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `appStatePath` | `string` | Path to Facebook appState/cookies JSON. |
|
|
22
|
+
| `appState` | `any[] \| string` | Optional in-memory appState alternative. |
|
|
23
|
+
| `sessionStorePath` | `string` | Optional path for login/session metadata used by E2EE bootstrap. |
|
|
24
|
+
| `platform` | `"facebook" \| "messenger"` | Login platform hint. Defaults to `"facebook"`. |
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Lifecycle
|
|
29
|
+
|
|
30
|
+
### `connect()`
|
|
31
|
+
|
|
32
|
+
Initializes the minimal `fca-unofficial` login bridge required for appState auth/CAT bootstrap and stores session metadata when configured. It does **not** expose or start plaintext/non-E2EE messaging/listening.
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
const { userId } = await client.connect();
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Returns `Promise<{ userId: string }>`.
|
|
39
|
+
|
|
40
|
+
### `connectE2EE(deviceStorePath: string, userId: string)`
|
|
41
|
+
|
|
42
|
+
Enables the E2EE Noise/Signal stream. Must be called after `connect()`.
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
await client.connectE2EE("./device-store.json", userId);
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Behavior:
|
|
49
|
+
|
|
50
|
+
1. Loads the existing `DeviceStore` if `deviceStorePath` exists, otherwise creates one.
|
|
51
|
+
2. Registers through ICDC only when the store has no `jid_device` yet.
|
|
52
|
+
3. Performs the Noise handshake with the E2EE websocket.
|
|
53
|
+
4. Sends presence/priming/passive-state nodes.
|
|
54
|
+
5. Runs startup prekey sync and starts periodic prekey maintenance.
|
|
55
|
+
6. Optionally connects DGW if DGW env settings are enabled.
|
|
56
|
+
|
|
57
|
+
### `disconnect()`
|
|
58
|
+
|
|
59
|
+
Stops heartbeats, periodic prekey maintenance, DGW/E2EE sockets, and the internal auth bridge.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Device Store & Key Maintenance
|
|
64
|
+
|
|
65
|
+
`device-store.json` is the long-lived E2EE device identity and cryptographic state. Keep it persistent between restarts.
|
|
66
|
+
|
|
67
|
+
### Important fields
|
|
68
|
+
|
|
69
|
+
| Field | Purpose | Rotate automatically? |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `noise_key_priv` | Noise handshake private key | No |
|
|
72
|
+
| `identity_key_priv` | Signal identity private key | No |
|
|
73
|
+
| `registration_id` | Signal registration ID | No |
|
|
74
|
+
| `adv_secret_key` | Messenger/WA companion secret | No |
|
|
75
|
+
| `facebook_uuid` | ICDC device UUID | No |
|
|
76
|
+
| `jid_user`, `jid_device` | Registered Messenger E2EE device JID | No |
|
|
77
|
+
| `pre_keys` | Local one-time prekey records | Yes, by upload/refill |
|
|
78
|
+
| `signed_pre_keys` / `signed_pre_key_id` | Signed prekey records | Yes, when uploading fresh prekeys |
|
|
79
|
+
| `sessions` | Signal sessions with devices | Updated by libsignal |
|
|
80
|
+
| `sender_keys` | Group sender-key state from SKDM | Updated when SKDM is received |
|
|
81
|
+
|
|
82
|
+
Do **not** delete the entire device store just because listening stops. Deleting it forces new device registration and loses sessions/sender keys. Prefer reconnecting and letting the prekey maintenance refill server-side prekeys.
|
|
83
|
+
|
|
84
|
+
### Automatic prekey maintenance
|
|
85
|
+
|
|
86
|
+
The controller checks server-side one-time prekey count after E2EE connect and then periodically.
|
|
87
|
+
|
|
88
|
+
| Env | Default | Description |
|
|
89
|
+
|---|---:|---|
|
|
90
|
+
| `FB_E2EE_PREKEY_SYNC_INTERVAL_MS` | `1800000` | Periodic prekey sync interval in milliseconds. Set `0` to disable. |
|
|
91
|
+
| `FB_E2EE_PREKEY_MIN_COUNT` | `5` | Minimum server prekey count before refill. |
|
|
92
|
+
| `FB_E2EE_PREKEY_UPLOAD_COUNT` | `50` | Number of fresh prekeys uploaded per refill. |
|
|
93
|
+
|
|
94
|
+
This refresh does not change the registered device identity. It only generates/uploads fresh one-time prekeys and a current signed prekey under the existing identity.
|
|
95
|
+
|
|
96
|
+
### Group sender-key caveat
|
|
97
|
+
|
|
98
|
+
A group `skmsg` needs a matching local `sender_keys` record. If the local sender key for a group/sender is truly missing, the client cannot derive it locally. On retryable decrypt failures, the receive path sends a `receipt type="retry"` with registration/key material to ask the sender/server to resend enough data to rebuild the session/SKDM. When a fresh SKDM arrives, it is processed and stored automatically. For messages sent by this client, a short in-memory retry cache stores the encrypted app payload/franking tag so incoming retry receipts can be answered with a targeted retry message and fresh SKDM without re-registering the device.
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## E2EE-only public API
|
|
103
|
+
|
|
104
|
+
This package no longer delegates plaintext/non-E2EE actions to `fca-unofficial`.
|
|
105
|
+
Use this package for Messenger E2EE only; use `fca-unofficial` directly in your app for classic/non-E2EE messaging, thread management, history, polls, etc.
|
|
106
|
+
|
|
107
|
+
All send APIs require:
|
|
108
|
+
|
|
109
|
+
1. `await client.connect()`
|
|
110
|
+
2. `await client.connectE2EE(deviceStorePath, userId)`
|
|
111
|
+
3. an E2EE-capable thread identifier (`1234567890`, `1234567890.0@msgr`, or `180...@g.us`)
|
|
112
|
+
|
|
113
|
+
### `sendMessage(input: SendMessageInput)`
|
|
114
|
+
|
|
115
|
+
Sends an E2EE text message. There is no plaintext FCA fallback.
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
const sent = await client.sendMessage({
|
|
119
|
+
threadId: "1234567890.0@msgr",
|
|
120
|
+
text: "hello",
|
|
121
|
+
replyToMessageId: "optional-message-id",
|
|
122
|
+
});
|
|
123
|
+
console.log(sent.messageId);
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**SendMessageInput**
|
|
127
|
+
|
|
128
|
+
| Field | Type | Description |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| `threadId` | `string` | Numeric user ID, `@msgr` JID, or group JID. |
|
|
131
|
+
| `text` | `string` | Message body. |
|
|
132
|
+
| `replyToMessageId` | `string` | Optional replied message ID. |
|
|
133
|
+
|
|
134
|
+
### `sendReaction(input: SendReactionInput)`
|
|
135
|
+
|
|
136
|
+
Sends an E2EE reaction. For group messages from someone else, pass `senderJid` so the target `MessageKey` is encoded correctly.
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
await client.sendReaction({
|
|
140
|
+
threadId: "1805602490133470@g.us",
|
|
141
|
+
messageId: "7456658723671758234",
|
|
142
|
+
senderJid: "100042415119261.145@msgr",
|
|
143
|
+
reaction: "👍",
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### `unsendMessage(messageId: string, threadId?: string)`
|
|
148
|
+
|
|
149
|
+
Un-sends/revokes an E2EE message you previously sent. Pass `threadId` unless the original send result is still in the short outbound cache.
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
await client.unsendMessage(sent.messageId, "1234567890.0@msgr");
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### `sendTyping(input: TypingInput)`
|
|
156
|
+
|
|
157
|
+
Sends E2EE chatstate (`composing` / `paused`) over the Noise socket.
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
await client.sendTyping({ threadId: "1234567890.0@msgr", isTyping: true });
|
|
161
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
162
|
+
await client.sendTyping({ threadId: "1234567890.0@msgr", isTyping: false });
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## E2EE Media Handling
|
|
168
|
+
|
|
169
|
+
### `sendImage` / `sendVideo` / `sendAudio` / `sendFile`
|
|
170
|
+
|
|
171
|
+
These helpers encrypt, upload, and send E2EE media for one-to-one Messenger E2EE chats. MIME type is inferred from `fileName` when omitted. Group E2EE media send is not implemented yet.
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
await client.sendImage({
|
|
175
|
+
threadId: "1234567890.0@msgr",
|
|
176
|
+
data: imageBuffer,
|
|
177
|
+
fileName: "image.jpg",
|
|
178
|
+
caption: "optional caption",
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Event Handling
|
|
185
|
+
|
|
186
|
+
### Catch-all listener
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
client.onEvent((event) => {
|
|
190
|
+
console.log(event.type, event.data);
|
|
191
|
+
});
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Typed listener
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
client.onEvent("e2ee_message", (msg) => {
|
|
198
|
+
console.log(msg.threadId, msg.chatJid, msg.senderJid, msg.kind, msg.text);
|
|
199
|
+
});
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### E2EE message event shape
|
|
203
|
+
|
|
204
|
+
`e2ee_message` uses `type + data` like other events, but `data` separates conversation identity from sender device identity:
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
{
|
|
208
|
+
type: "e2ee_message",
|
|
209
|
+
data: {
|
|
210
|
+
id: "7456191609143713633",
|
|
211
|
+
threadId: "100042415119261",
|
|
212
|
+
chatJid: "100042415119261.0@msgr",
|
|
213
|
+
senderJid: "100042415119261.160@msgr",
|
|
214
|
+
senderId: "100042415119261",
|
|
215
|
+
senderDeviceId: 160,
|
|
216
|
+
isGroup: false,
|
|
217
|
+
kind: "text",
|
|
218
|
+
text: "Hehe",
|
|
219
|
+
timestampMs: 1777694609888,
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
For group messages, `threadId` and `chatJid` stay as the group JID, while `senderJid` remains the actual sender device JID. Optional fields such as `attachments`, `mentions`, and `replyTo` are omitted when empty.
|
|
225
|
+
|
|
226
|
+
Common event types:
|
|
227
|
+
|
|
228
|
+
- `message`
|
|
229
|
+
- `messageEdit`
|
|
230
|
+
- `reaction`
|
|
231
|
+
- `typing`
|
|
232
|
+
- `message_unsend`
|
|
233
|
+
- `read_receipt`
|
|
234
|
+
- `presence`
|
|
235
|
+
- `e2ee_connected`
|
|
236
|
+
- `e2ee_message`
|
|
237
|
+
- `e2ee_reaction`
|
|
238
|
+
- `e2ee_receipt`
|
|
239
|
+
- `disconnected`
|
|
240
|
+
- `reconnected`
|
|
241
|
+
- `ready`
|
|
242
|
+
- `raw`
|
|
243
|
+
- `error`
|
|
244
|
+
|
|
245
|
+
`error` is also routed through the catch-all `event` channel. The internal emitter avoids Node's unhandled `error` event crash when no typed error listener is registered.
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## E2EE Technical Details
|
|
250
|
+
|
|
251
|
+
### Internal module layout
|
|
252
|
+
|
|
253
|
+
- `src/e2ee/application`: `E2EEClient`, retry manager, prekey maintenance, outbound retry cache, fanout/JID helpers.
|
|
254
|
+
- `src/e2ee/store`: `DeviceStore`, JSON schema/migration helpers, file repository.
|
|
255
|
+
- `src/e2ee/transport`: Noise socket/handshake, WA-binary encoder/decoder/stanzas, optional DGW socket.
|
|
256
|
+
- `src/e2ee/signal`: Signal sessions, prekeys, sender-key group cipher helpers.
|
|
257
|
+
- `src/e2ee/message`: `ProtoWriter`, client/consumer/application/transport builders, protobuf codecs/schemas.
|
|
258
|
+
- `src/e2ee/media` and `src/e2ee/facebook`: media crypto/upload and Facebook-specific protocol helpers.
|
|
259
|
+
|
|
260
|
+
Legacy `src/e2ee/*.ts` shim files still re-export the new modules for compatibility.
|
|
261
|
+
|
|
262
|
+
### Receive path
|
|
263
|
+
|
|
264
|
+
1. `FacebookE2EESocket` decrypts Noise frames.
|
|
265
|
+
2. `ClientController` unmarshals WA-binary nodes.
|
|
266
|
+
3. `E2EEHandler` ACKs, processes participant SKDM/direct participant payloads, decrypts `msg` / `pkmsg` / `skmsg`, decodes protobuf payloads, and emits normalized events.
|
|
267
|
+
4. Retryable decrypt failures emit an `error` event and send an E2EE retry receipt rather than terminating the listener loop.
|
|
268
|
+
|
|
269
|
+
### Send path
|
|
270
|
+
|
|
271
|
+
- DM: build MessageTransport, establish/fetch sessions when needed, fan out encrypted device payloads.
|
|
272
|
+
- Group: fetch participants/devices, build group `skmsg`, distribute `skdm` to devices through `<participants>`, include `phash`, `franking`, and `trace` nodes.
|
|
273
|
+
- Outgoing E2EE payloads are cached briefly by `OutboundMessageCache`; `E2EERetryManager` uses that cache to respond to `receipt type="retry"` without re-registering the device.
|
|
274
|
+
|
|
275
|
+
### Requirements
|
|
276
|
+
|
|
277
|
+
- A valid `appState` / cookies file.
|
|
278
|
+
- A persistent device store JSON.
|
|
279
|
+
- No plaintext fallback for E2EE send failures.
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## Environment Variables
|
|
284
|
+
|
|
285
|
+
| Env | Default | Description |
|
|
286
|
+
|---|---|---|
|
|
287
|
+
| `FB_APPSTATE_PATH` | `./data/appstate.json` | AppState/cookies path used by env helpers/examples. |
|
|
288
|
+
| `FB_SESSION_STORE_PATH` | `./data/session.json` | Non-E2EE session metadata path. |
|
|
289
|
+
| `FB_PLATFORM` | `facebook` | Platform hint. |
|
|
290
|
+
| `DEBUG` / `NODE_ENV=development` | off | Enables debug logger output. |
|
|
291
|
+
| `FB_E2EE_PREKEY_SYNC_INTERVAL_MS` | `1800000` | Periodic E2EE prekey sync interval. |
|
|
292
|
+
| `FB_E2EE_PREKEY_MIN_COUNT` | `5` | Minimum server prekey count before refill. |
|
|
293
|
+
| `FB_E2EE_PREKEY_UPLOAD_COUNT` | `50` | Fresh prekeys uploaded per refill. |
|
|
294
|
+
| `FB_DGW_ENABLE` | unset | Enables optional DGW connection when set to `1`. |
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## Manual Scripts
|
|
299
|
+
|
|
300
|
+
```bash
|
|
301
|
+
bun run tests/script/echo-e2ee.ts
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
The echo script keeps the process alive by default. Set `ECHO_EXIT_AFTER_MS` to auto-exit for short manual tests.
|
|
305
|
+
|
|
306
|
+
---
|
|
307
|
+
|
|
308
|
+
## Support
|
|
309
|
+
|
|
310
|
+
For bugs and feature requests, please open an issue in the repository.
|