was-sticker 0.1.0 → 2.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/README.md +115 -68
- package/package.json +19 -5
- package/src/capture.js +118 -0
- package/src/cli.js +82 -53
- package/src/customize.js +72 -0
- package/src/extract.js +117 -0
- package/src/index.js +18 -231
- package/src/send.js +76 -0
- package/src/mime.js +0 -33
- package/templates/pulse/animation.json +0 -1
package/README.md
CHANGED
|
@@ -3,20 +3,23 @@
|
|
|
3
3
|
[](https://github.com/thxmxx/was-sticker/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/was-sticker)
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
Re-brand WhatsApp Lottie stickers (`.was`) and send them through Baileys as a proper `lottieStickerMessage` — so they actually render on mobile.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
8
|
+
> ⚠️ **What this is and isn't.** WhatsApp signs every animated Lottie sticker with an ES256 JWT (`trust_token`) carrying the SHA-256 of the animation JSON. Any change to the animation invalidates the token and the client silently drops the sticker. This library does **not** let you put your own image inside a Lottie animation. What it lets you do is **re-brand** a genuine WhatsApp Lottie sticker (change the pack name, publisher, emojis, group link) without breaking the signature. The animation itself stays a Meta-original sticker.
|
|
9
|
+
>
|
|
10
|
+
> If you want a sticker with your own artwork, you need WebP animated, not Lottie `.was`. That is not what this package does.
|
|
11
|
+
|
|
12
|
+
## How it actually works
|
|
13
|
+
|
|
14
|
+
1. **Source** a `.was` whose `trust_token` is genuine. The library ships a Baileys helper, `captureNextLottieSticker`, that listens for the next animated sticker delivered to your account and saves the encrypted `.was` to disk.
|
|
15
|
+
2. **Customize** only the `overridden_metadata` inside the archive. The animation JSON and the trust_token are preserved byte-for-byte, so the client-side SHA check still passes.
|
|
16
|
+
3. **Send** via `sendLottieSticker`, which wraps your sticker in a `lottieStickerMessage` (FutureProofMessage at field 74). Baileys' regular `sock.sendMessage({ sticker, mimetype })` emits `stickerMessage` (field 26) — Web tolerates it, mobile does not.
|
|
15
17
|
|
|
16
18
|
## Install
|
|
17
19
|
|
|
18
20
|
```bash
|
|
19
21
|
npm install was-sticker
|
|
22
|
+
npm install @whiskeysockets/baileys # peer dep, only needed for send/capture
|
|
20
23
|
```
|
|
21
24
|
|
|
22
25
|
Requires Node.js ≥ 18.
|
|
@@ -24,91 +27,135 @@ Requires Node.js ≥ 18.
|
|
|
24
27
|
## Quick start
|
|
25
28
|
|
|
26
29
|
```js
|
|
27
|
-
import {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
31
|
+
import {
|
|
32
|
+
inspectWAS,
|
|
33
|
+
customizeMetadata,
|
|
34
|
+
sendLottieSticker,
|
|
35
|
+
captureNextLottieSticker,
|
|
36
|
+
} from 'was-sticker';
|
|
37
|
+
|
|
38
|
+
// 1. Source a .was by waiting for one to arrive.
|
|
39
|
+
const { buffer } = await captureNextLottieSticker(sock, { timeoutMs: 120_000 });
|
|
40
|
+
await writeFile('./pumpkin.was', buffer);
|
|
41
|
+
|
|
42
|
+
// 2. Inspect it (sanity check).
|
|
43
|
+
console.log(await inspectWAS(buffer));
|
|
44
|
+
// → { animation: { nm: 'WA_Harvest_…', w: 512, h: 512, fps: 60, ... },
|
|
45
|
+
// trustToken: { kid: '196', alg: 'ES256', claimedSha: '...' },
|
|
46
|
+
// shaMatches: true, ... }
|
|
47
|
+
|
|
48
|
+
// 3. Re-brand it.
|
|
49
|
+
const rebranded = await customizeMetadata(buffer, {
|
|
50
|
+
packId: 'my-bot-pack-v1',
|
|
51
|
+
packName: 'My Bot Pack',
|
|
52
|
+
publisher: 'Bot\nMade with was-sticker',
|
|
53
|
+
accessibilityText: 'A custom animated sticker',
|
|
54
|
+
emojis: ['💎', '✨'],
|
|
32
55
|
});
|
|
33
|
-
// → buffer is a ready-to-send `.was` sticker
|
|
34
|
-
```
|
|
35
56
|
|
|
36
|
-
|
|
57
|
+
// 4. Send it on Baileys with the right wrapper.
|
|
58
|
+
await sendLottieSticker(sock, '5511…@s.whatsapp.net', rebranded);
|
|
59
|
+
```
|
|
37
60
|
|
|
38
61
|
## API
|
|
39
62
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const { buffer, output, mime } = await buildLottieSticker({
|
|
44
|
-
image: 'face.png', // path | Buffer | { buffer, path, mime }
|
|
45
|
-
template: './templates/heart', // folder | JSON path | Buffer | parsed object
|
|
46
|
-
output: './out/heart.was', // optional — omit to keep it in-memory
|
|
47
|
-
assetSelector: 0, // optional — number | string (id) | (asset) => boolean
|
|
48
|
-
jsonEntryName: 'animation/anim.json', // optional — path inside the .was for the JSON
|
|
49
|
-
extraFiles: { 'meta.json': '{}' }, // optional — additional files to bundle
|
|
50
|
-
});
|
|
51
|
-
```
|
|
63
|
+
### `extractFromWAS(buffer)`
|
|
64
|
+
|
|
65
|
+
Parses the ZIP archive. Returns `{ files, jsonPath, animation, trustToken, trustTokenPath, metadata, metadataPath }`.
|
|
52
66
|
|
|
53
|
-
|
|
54
|
-
| ------------------- | ----------------------------------------------------------- |
|
|
55
|
-
| `image: 'face.png'` | Reads file, sniffs MIME from extension and magic bytes. |
|
|
56
|
-
| `image: buffer` | Sniffs MIME from magic bytes. |
|
|
57
|
-
| `image: { buffer, mime }` | Trust caller-provided MIME. |
|
|
58
|
-
| `template: './folder'` | Walks folder; treats `*.json` as the Lottie, bundles the rest. |
|
|
59
|
-
| `template: './lottie.json'` | Reads and parses the single file. |
|
|
60
|
-
| `template: object` | Uses the parsed Lottie (deep-cloned, not mutated). |
|
|
67
|
+
### `inspectWAS(buffer)`
|
|
61
68
|
|
|
62
|
-
|
|
69
|
+
Higher-level summary intended for CLI / debugging output:
|
|
63
70
|
|
|
64
71
|
```ts
|
|
65
|
-
{
|
|
72
|
+
{
|
|
73
|
+
jsonPath: string,
|
|
74
|
+
animation: { nm, version, width, height, fps, durationFrames, layers, assets },
|
|
75
|
+
metadata: object | null,
|
|
76
|
+
trustToken: { kid, alg, stickerFileType, trustedOrigin, claimedSha } | null,
|
|
77
|
+
sha256: string,
|
|
78
|
+
shaMatches: boolean, // <-- this is the only field that ultimately matters
|
|
79
|
+
size: number,
|
|
80
|
+
fileNames: string[],
|
|
81
|
+
}
|
|
66
82
|
```
|
|
67
83
|
|
|
68
|
-
|
|
84
|
+
If `shaMatches` is `false`, the WhatsApp client will reject the sticker.
|
|
69
85
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
was
|
|
86
|
+
### `customizeMetadata(buffer, patch, { merge = true })`
|
|
87
|
+
|
|
88
|
+
Returns a new `.was` buffer with `overridden_metadata` rewritten. Allowed `patch` fields:
|
|
89
|
+
|
|
90
|
+
| Camel-case input | Underlying WhatsApp field |
|
|
91
|
+
| ------------------- | --------------------------------- |
|
|
92
|
+
| `packId` | `sticker-pack-id` |
|
|
93
|
+
| `packName` | `sticker-pack-name` |
|
|
94
|
+
| `publisher` | `sticker-pack-publisher` (newlines OK — the second line typically holds a group link) |
|
|
95
|
+
| `accessibilityText` | `accessibility-text` |
|
|
96
|
+
| `emojis` | `emojis` (array of glyphs) |
|
|
97
|
+
| `isFromUserCreatedPack` | `is-from-user-created-pack` (default `1`) |
|
|
98
|
+
|
|
99
|
+
`merge: false` rewrites the metadata from scratch instead of merging with what's already there.
|
|
100
|
+
|
|
101
|
+
The animation JSON and the trust_token are not touched.
|
|
102
|
+
|
|
103
|
+
### `sendLottieSticker(sock, jid, buffer, opts?)`
|
|
104
|
+
|
|
105
|
+
Uploads `buffer` via Baileys' `prepareWAMessageMedia`, then relays it as a `lottieStickerMessage`.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
opts?: {
|
|
109
|
+
width?: number,
|
|
110
|
+
height?: number,
|
|
111
|
+
accessibilityLabel?: string,
|
|
112
|
+
messageId?: string,
|
|
113
|
+
quoted?: WAMessage,
|
|
114
|
+
}
|
|
73
115
|
```
|
|
74
116
|
|
|
75
|
-
|
|
117
|
+
Returns `{ messageId, fileLength }`.
|
|
76
118
|
|
|
77
|
-
|
|
78
|
-
import { buildLottieSticker } from 'was-sticker';
|
|
119
|
+
### `captureNextLottieSticker(sock, opts?)`
|
|
79
120
|
|
|
80
|
-
|
|
81
|
-
image: incomingPhotoBuffer, // a Buffer you already have in memory
|
|
82
|
-
template: './templates/heart',
|
|
83
|
-
});
|
|
121
|
+
Resolves with the next incoming Lottie sticker, downloaded and decrypted.
|
|
84
122
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
123
|
+
```ts
|
|
124
|
+
opts?: {
|
|
125
|
+
timeoutMs?: number, // default 60_000; 0 = no timeout
|
|
126
|
+
from?: string, // restrict to one JID
|
|
127
|
+
filter?: (stk, key) => boolean,
|
|
128
|
+
includeNonLottie?: boolean, // default false
|
|
129
|
+
}
|
|
89
130
|
```
|
|
90
131
|
|
|
91
|
-
|
|
132
|
+
Returns `{ buffer, stickerMessage, key, mimetype, isLottie, isAnimated, width, height }`.
|
|
133
|
+
|
|
134
|
+
## CLI
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
was-sticker inspect <in.was>
|
|
138
|
+
was-sticker customize <in.was> -o <out.was> \
|
|
139
|
+
--pack-name "My Pack" \
|
|
140
|
+
--publisher "Me\nGrupo: https://example.com" \
|
|
141
|
+
--emoji 🎃 --emoji 💎
|
|
142
|
+
```
|
|
92
143
|
|
|
93
|
-
|
|
144
|
+
`inspect` prints the JSON summary above. `customize` writes the rebranded archive.
|
|
94
145
|
|
|
95
|
-
|
|
96
|
-
2. Drop it into a folder, e.g. `./templates/heart/animation/animation.json`.
|
|
97
|
-
3. Pass that folder as `template`.
|
|
146
|
+
Sending and capturing are intentionally not in the CLI — both require a connected Baileys socket; do them from a script (see `examples/`).
|
|
98
147
|
|
|
99
|
-
|
|
148
|
+
## Examples
|
|
100
149
|
|
|
101
|
-
|
|
150
|
+
- [`examples/capture.js`](./examples/capture.js) — wait for a forwarded sticker and save it.
|
|
151
|
+
- [`examples/customize-and-send.js`](./examples/customize-and-send.js) — rebrand and send.
|
|
102
152
|
|
|
103
|
-
|
|
153
|
+
## Notes / gotchas
|
|
104
154
|
|
|
105
|
-
-
|
|
106
|
-
- **
|
|
107
|
-
- **
|
|
108
|
-
- **Validation pass** — warn when the produced `.was` exceeds WhatsApp's recommended size, or when frame rate × duration looks off.
|
|
109
|
-
- **TypeScript declarations** — emit `.d.ts` alongside the JSDoc.
|
|
110
|
-
- **Streaming output** — for very large bundles, stream the ZIP to disk instead of buffering.
|
|
155
|
+
- **Mobile vs Web parity.** Mobile WhatsApp will silently drop Lottie payloads that arrive as plain `stickerMessage`. Web will render them. Use `sendLottieSticker`, not `sock.sendMessage({ sticker, mimetype: 'application/was' })`.
|
|
156
|
+
- **The pack info shown in Web** comes from WhatsApp's official catalog keyed by the animation's identity, not from your `overridden_metadata`. Mobile reads your metadata. This is a WhatsApp quirk, not a bug.
|
|
157
|
+
- **Use at your own risk.** Sending modified `.was` files through unofficial clients (Baileys is one) violates WhatsApp's Terms of Service and can result in your number being banned. Test on a throwaway account.
|
|
111
158
|
|
|
112
159
|
## License
|
|
113
160
|
|
|
114
|
-
MIT.
|
|
161
|
+
MIT — see [LICENSE](./LICENSE).
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "was-sticker",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Re-brand WhatsApp Lottie stickers (.was) and send them via Baileys as a real lottieStickerMessage.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"src",
|
|
15
|
-
"templates",
|
|
16
15
|
"README.md",
|
|
17
16
|
"LICENSE"
|
|
18
17
|
],
|
|
@@ -20,10 +19,17 @@
|
|
|
20
19
|
"node": ">=18"
|
|
21
20
|
},
|
|
22
21
|
"scripts": {
|
|
23
|
-
"example": "node examples/build.js",
|
|
24
22
|
"test": "node --test test/smoke.test.js"
|
|
25
23
|
},
|
|
26
|
-
"keywords": [
|
|
24
|
+
"keywords": [
|
|
25
|
+
"whatsapp",
|
|
26
|
+
"sticker",
|
|
27
|
+
"lottie",
|
|
28
|
+
"was",
|
|
29
|
+
"animated-sticker",
|
|
30
|
+
"baileys",
|
|
31
|
+
"lottieStickerMessage"
|
|
32
|
+
],
|
|
27
33
|
"author": "thxmxx",
|
|
28
34
|
"license": "MIT",
|
|
29
35
|
"repository": {
|
|
@@ -36,5 +42,13 @@
|
|
|
36
42
|
},
|
|
37
43
|
"dependencies": {
|
|
38
44
|
"jszip": "^3.10.1"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@whiskeysockets/baileys": ">=6.7.0"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"@whiskeysockets/baileys": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
39
53
|
}
|
|
40
54
|
}
|
package/src/capture.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Listen for the next incoming Lottie sticker and return its decrypted buffer.
|
|
3
|
+
* Useful for sourcing genuine `.was` files (with Meta-signed trust_tokens) that
|
|
4
|
+
* you can then customize with `customizeMetadata`.
|
|
5
|
+
*
|
|
6
|
+
* WhatsApp routes Lottie stickers via `lottieStickerMessage` (FutureProofMessage
|
|
7
|
+
* at field 74) — Baileys' default `downloadMediaMessage` doesn't handle that
|
|
8
|
+
* variant. This helper walks the message tree, finds the inner stickerMessage,
|
|
9
|
+
* and uses the lower-level `downloadContentFromMessage` to decrypt.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
async function loadBaileys() {
|
|
13
|
+
try {
|
|
14
|
+
return await import('@whiskeysockets/baileys');
|
|
15
|
+
} catch {
|
|
16
|
+
throw new Error(
|
|
17
|
+
'captureNextLottieSticker requires "@whiskeysockets/baileys" — install it as a peer dependency.',
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function findStickerMessage(msg) {
|
|
23
|
+
if (!msg || typeof msg !== 'object') return null;
|
|
24
|
+
if (msg.stickerMessage) return msg.stickerMessage;
|
|
25
|
+
for (const v of Object.values(msg)) {
|
|
26
|
+
if (v && typeof v === 'object') {
|
|
27
|
+
const inner = findStickerMessage(v);
|
|
28
|
+
if (inner) return inner;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve when the next sticker (Lottie or otherwise) matching `filter` arrives.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} sock — connected Baileys socket
|
|
38
|
+
* @param {{
|
|
39
|
+
* timeoutMs?: number, // default 60_000; 0 = no timeout
|
|
40
|
+
* from?: string, // restrict to a specific JID
|
|
41
|
+
* filter?: (stickerMessage, key) => boolean // custom predicate
|
|
42
|
+
* includeNonLottie?: boolean, // default false — only Lottie/.was
|
|
43
|
+
* }} [opts]
|
|
44
|
+
* @returns {Promise<{
|
|
45
|
+
* buffer: Buffer,
|
|
46
|
+
* stickerMessage: object,
|
|
47
|
+
* key: { remoteJid: string, id: string, fromMe: boolean },
|
|
48
|
+
* mimetype: string,
|
|
49
|
+
* isLottie: boolean,
|
|
50
|
+
* isAnimated: boolean,
|
|
51
|
+
* width: number,
|
|
52
|
+
* height: number,
|
|
53
|
+
* }>}
|
|
54
|
+
*/
|
|
55
|
+
export function captureNextLottieSticker(sock, opts = {}) {
|
|
56
|
+
if (!sock || typeof sock.ev?.on !== 'function') {
|
|
57
|
+
throw new Error('captureNextLottieSticker: first argument must be a Baileys socket.');
|
|
58
|
+
}
|
|
59
|
+
const { timeoutMs = 60_000, from, filter, includeNonLottie = false } = opts;
|
|
60
|
+
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
let settled = false;
|
|
63
|
+
let timer = null;
|
|
64
|
+
|
|
65
|
+
const onUpsert = async ({ messages }) => {
|
|
66
|
+
if (settled) return;
|
|
67
|
+
for (const m of messages) {
|
|
68
|
+
if (settled) break;
|
|
69
|
+
if (!m.message) continue;
|
|
70
|
+
if (from && m.key.remoteJid !== from) continue;
|
|
71
|
+
|
|
72
|
+
const stk = findStickerMessage(m.message);
|
|
73
|
+
if (!stk) continue;
|
|
74
|
+
if (!includeNonLottie && !stk.isLottie) continue;
|
|
75
|
+
if (filter && !filter(stk, m.key)) continue;
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const { downloadContentFromMessage } = await loadBaileys();
|
|
79
|
+
const stream = await downloadContentFromMessage(stk, 'sticker');
|
|
80
|
+
const chunks = [];
|
|
81
|
+
for await (const c of stream) chunks.push(c);
|
|
82
|
+
const buffer = Buffer.concat(chunks);
|
|
83
|
+
|
|
84
|
+
settled = true;
|
|
85
|
+
if (timer) clearTimeout(timer);
|
|
86
|
+
sock.ev.off('messages.upsert', onUpsert);
|
|
87
|
+
resolve({
|
|
88
|
+
buffer,
|
|
89
|
+
stickerMessage: stk,
|
|
90
|
+
key: m.key,
|
|
91
|
+
mimetype: stk.mimetype ?? null,
|
|
92
|
+
isLottie: !!stk.isLottie,
|
|
93
|
+
isAnimated: !!stk.isAnimated,
|
|
94
|
+
width: stk.width ?? 0,
|
|
95
|
+
height: stk.height ?? 0,
|
|
96
|
+
});
|
|
97
|
+
} catch (err) {
|
|
98
|
+
settled = true;
|
|
99
|
+
if (timer) clearTimeout(timer);
|
|
100
|
+
sock.ev.off('messages.upsert', onUpsert);
|
|
101
|
+
reject(err);
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
sock.ev.on('messages.upsert', onUpsert);
|
|
108
|
+
|
|
109
|
+
if (timeoutMs > 0) {
|
|
110
|
+
timer = setTimeout(() => {
|
|
111
|
+
if (settled) return;
|
|
112
|
+
settled = true;
|
|
113
|
+
sock.ev.off('messages.upsert', onUpsert);
|
|
114
|
+
reject(new Error(`captureNextLottieSticker: timed out after ${timeoutMs}ms.`));
|
|
115
|
+
}, timeoutMs);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -1,26 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
|
-
import {
|
|
3
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
4
5
|
|
|
5
|
-
import {
|
|
6
|
+
import { inspectWAS } from './extract.js';
|
|
7
|
+
import { customizeMetadata } from './customize.js';
|
|
6
8
|
|
|
7
|
-
const USAGE = `was-sticker —
|
|
9
|
+
const USAGE = `was-sticker — inspect and re-brand WhatsApp Lottie stickers (.was).
|
|
8
10
|
|
|
9
11
|
Usage:
|
|
10
|
-
was-sticker
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
was-sticker -i face.png -t lottie.json -s image_0
|
|
12
|
+
was-sticker inspect <in.was>
|
|
13
|
+
was-sticker customize <in.was> -o <out.was>
|
|
14
|
+
[--pack-id ID] [--pack-name NAME]
|
|
15
|
+
[--publisher PUBLISHER] [--accessibility-text TEXT]
|
|
16
|
+
[--emoji EMOJI ... | --emojis "🎃,🎉,💎"]
|
|
17
|
+
[--no-merge]
|
|
18
|
+
|
|
19
|
+
Subcommands:
|
|
20
|
+
inspect Show the Lottie metadata, trust-token claims, and SHA match.
|
|
21
|
+
customize Rewrite only the overridden_metadata; emits a new .was.
|
|
22
|
+
|
|
23
|
+
For sending and capturing .was files, use the JS API:
|
|
24
|
+
import { sendLottieSticker, captureNextLottieSticker } from 'was-sticker';
|
|
24
25
|
`;
|
|
25
26
|
|
|
26
27
|
function fail(msg, code = 1) {
|
|
@@ -28,46 +29,74 @@ function fail(msg, code = 1) {
|
|
|
28
29
|
process.exit(code);
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
template: { type: 'string', short: 't' },
|
|
35
|
-
out: { type: 'string', short: 'o' },
|
|
36
|
-
selector: { type: 'string', short: 's' },
|
|
37
|
-
'json-entry': { type: 'string' },
|
|
38
|
-
help: { type: 'boolean', short: 'h' },
|
|
39
|
-
},
|
|
40
|
-
allowPositionals: false,
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
if (values.help) {
|
|
32
|
+
const [, , sub, ...rest] = process.argv;
|
|
33
|
+
|
|
34
|
+
if (!sub || sub === '-h' || sub === '--help') {
|
|
44
35
|
process.stdout.write(USAGE);
|
|
45
|
-
process.exit(0);
|
|
36
|
+
process.exit(sub ? 0 : 1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function readInput(positional) {
|
|
40
|
+
if (!positional) fail('Missing <in.was>.');
|
|
41
|
+
return readFile(resolve(positional));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function writeOutput(path, buffer) {
|
|
45
|
+
const abs = resolve(path);
|
|
46
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
47
|
+
await writeFile(abs, buffer);
|
|
48
|
+
return abs;
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
if (sub === 'inspect') {
|
|
52
|
+
const buffer = await readInput(rest[0]);
|
|
53
|
+
const info = await inspectWAS(buffer);
|
|
54
|
+
process.stdout.write(JSON.stringify(info, null, 2) + '\n');
|
|
55
|
+
process.exit(0);
|
|
51
56
|
}
|
|
52
57
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
jsonEntryName: values['json-entry'],
|
|
58
|
+
if (sub === 'customize') {
|
|
59
|
+
const { values, positionals } = parseArgs({
|
|
60
|
+
args: rest,
|
|
61
|
+
allowPositionals: true,
|
|
62
|
+
options: {
|
|
63
|
+
out: { type: 'string', short: 'o' },
|
|
64
|
+
'pack-id': { type: 'string' },
|
|
65
|
+
'pack-name': { type: 'string' },
|
|
66
|
+
publisher: { type: 'string' },
|
|
67
|
+
'accessibility-text': { type: 'string' },
|
|
68
|
+
emoji: { type: 'string', multiple: true },
|
|
69
|
+
emojis: { type: 'string' },
|
|
70
|
+
'no-merge': { type: 'boolean' },
|
|
71
|
+
help: { type: 'boolean', short: 'h' },
|
|
72
|
+
},
|
|
69
73
|
});
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
|
|
75
|
+
if (values.help) {
|
|
76
|
+
process.stdout.write(USAGE);
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
if (!positionals[0]) fail('Missing <in.was>.');
|
|
80
|
+
if (!values.out) fail('Missing --out / -o.');
|
|
81
|
+
|
|
82
|
+
const buffer = await readInput(positionals[0]);
|
|
83
|
+
|
|
84
|
+
const emojis = values.emoji?.length
|
|
85
|
+
? values.emoji
|
|
86
|
+
: values.emojis?.split(',').map((s) => s.trim()).filter(Boolean);
|
|
87
|
+
|
|
88
|
+
const patch = {
|
|
89
|
+
...(values['pack-id'] && { packId: values['pack-id'] }),
|
|
90
|
+
...(values['pack-name'] && { packName: values['pack-name'] }),
|
|
91
|
+
...(values.publisher && { publisher: values.publisher }),
|
|
92
|
+
...(values['accessibility-text'] && { accessibilityText: values['accessibility-text'] }),
|
|
93
|
+
...(emojis && { emojis }),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const out = await customizeMetadata(buffer, patch, { merge: !values['no-merge'] });
|
|
97
|
+
const path = await writeOutput(values.out, out);
|
|
98
|
+
process.stdout.write(`${path} (${out.length.toLocaleString()} bytes)\n`);
|
|
99
|
+
process.exit(0);
|
|
73
100
|
}
|
|
101
|
+
|
|
102
|
+
fail(`Unknown subcommand "${sub}".\n\n${USAGE}`);
|
package/src/customize.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import JSZip from 'jszip';
|
|
2
|
+
import { extractFromWAS } from './extract.js';
|
|
3
|
+
|
|
4
|
+
const ALLOWED_FIELDS = Object.freeze({
|
|
5
|
+
packId: 'sticker-pack-id',
|
|
6
|
+
packName: 'sticker-pack-name',
|
|
7
|
+
publisher: 'sticker-pack-publisher',
|
|
8
|
+
accessibilityText: 'accessibility-text',
|
|
9
|
+
emojis: 'emojis',
|
|
10
|
+
isFromUserCreatedPack: 'is-from-user-created-pack',
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function patchToWaMetadata(patch) {
|
|
14
|
+
if (patch == null || typeof patch !== 'object') {
|
|
15
|
+
throw new Error('customizeMetadata: patch must be an object.');
|
|
16
|
+
}
|
|
17
|
+
const out = {};
|
|
18
|
+
for (const [camelKey, waKey] of Object.entries(ALLOWED_FIELDS)) {
|
|
19
|
+
if (patch[camelKey] !== undefined) out[waKey] = patch[camelKey];
|
|
20
|
+
}
|
|
21
|
+
// Pass through any "wa-style" keys verbatim (escape hatch for advanced users).
|
|
22
|
+
for (const k of Object.keys(patch)) {
|
|
23
|
+
if (Object.values(ALLOWED_FIELDS).includes(k)) out[k] = patch[k];
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Returns a new `.was` buffer with the overridden_metadata replaced or merged.
|
|
30
|
+
*
|
|
31
|
+
* The animation JSON and its trust_token are preserved byte-for-byte so the
|
|
32
|
+
* client-side SHA check still passes.
|
|
33
|
+
*
|
|
34
|
+
* @param {Buffer|Uint8Array} buffer
|
|
35
|
+
* @param {{
|
|
36
|
+
* packId?: string,
|
|
37
|
+
* packName?: string,
|
|
38
|
+
* publisher?: string,
|
|
39
|
+
* accessibilityText?: string,
|
|
40
|
+
* emojis?: string[],
|
|
41
|
+
* isFromUserCreatedPack?: 0 | 1,
|
|
42
|
+
* }} patch
|
|
43
|
+
* @param {{ merge?: boolean }} [opts] — when true (default), unspecified fields
|
|
44
|
+
* are kept from the existing metadata; when false, the metadata is rewritten
|
|
45
|
+
* from scratch using only the patch.
|
|
46
|
+
* @returns {Promise<Buffer>}
|
|
47
|
+
*/
|
|
48
|
+
export async function customizeMetadata(buffer, patch, opts = {}) {
|
|
49
|
+
const { merge = true } = opts;
|
|
50
|
+
const { files, jsonPath, metadata } = await extractFromWAS(buffer);
|
|
51
|
+
|
|
52
|
+
const waPatch = patchToWaMetadata(patch);
|
|
53
|
+
const next = merge ? { ...(metadata ?? {}), ...waPatch } : { ...waPatch };
|
|
54
|
+
|
|
55
|
+
// is-from-user-created-pack is expected by WhatsApp on custom packs — default to 1.
|
|
56
|
+
if (next['is-from-user-created-pack'] === undefined) {
|
|
57
|
+
next['is-from-user-created-pack'] = 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const metadataPath = `${jsonPath}.overridden_metadata`;
|
|
61
|
+
files[metadataPath] = Buffer.from(JSON.stringify(next), 'utf8');
|
|
62
|
+
|
|
63
|
+
const zip = new JSZip();
|
|
64
|
+
for (const [name, contents] of Object.entries(files)) {
|
|
65
|
+
zip.file(name, contents);
|
|
66
|
+
}
|
|
67
|
+
return zip.generateAsync({
|
|
68
|
+
type: 'nodebuffer',
|
|
69
|
+
compression: 'DEFLATE',
|
|
70
|
+
compressionOptions: { level: 6 },
|
|
71
|
+
});
|
|
72
|
+
}
|
package/src/extract.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import JSZip from 'jszip';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Decode the payload of a JWT trust_token (no signature verification — that's
|
|
6
|
+
* the WhatsApp client's job; we only inspect the claims).
|
|
7
|
+
*/
|
|
8
|
+
function decodeTrustToken(token) {
|
|
9
|
+
if (typeof token !== 'string') return null;
|
|
10
|
+
const [headerB64, payloadB64, sigB64] = token.split('.');
|
|
11
|
+
if (!headerB64 || !payloadB64 || !sigB64) return null;
|
|
12
|
+
const fromB64 = (s) => Buffer.from(s + '='.repeat((4 - (s.length % 4)) % 4), 'base64');
|
|
13
|
+
const header = JSON.parse(fromB64(headerB64).toString('utf8'));
|
|
14
|
+
const payload = JSON.parse(fromB64(payloadB64).toString('utf8'));
|
|
15
|
+
// payload.sticker_file_sha256 is base64url-encoded SHA-256
|
|
16
|
+
const claimedShaHex = payload.sticker_file_sha256
|
|
17
|
+
? fromB64(payload.sticker_file_sha256).toString('hex')
|
|
18
|
+
: null;
|
|
19
|
+
return { header, payload, claimedShaHex, raw: token };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse a `.was` archive.
|
|
24
|
+
*
|
|
25
|
+
* @param {Buffer|Uint8Array} buffer
|
|
26
|
+
* @returns {Promise<{
|
|
27
|
+
* files: Record<string, Buffer>,
|
|
28
|
+
* jsonPath: string,
|
|
29
|
+
* animation: object,
|
|
30
|
+
* trustToken: ReturnType<typeof decodeTrustToken> | null,
|
|
31
|
+
* trustTokenPath: string | null,
|
|
32
|
+
* metadata: object | null,
|
|
33
|
+
* metadataPath: string | null,
|
|
34
|
+
* }>}
|
|
35
|
+
*/
|
|
36
|
+
export async function extractFromWAS(buffer) {
|
|
37
|
+
if (!buffer || (!Buffer.isBuffer(buffer) && !(buffer instanceof Uint8Array))) {
|
|
38
|
+
throw new Error('extractFromWAS: buffer is required.');
|
|
39
|
+
}
|
|
40
|
+
const zip = await JSZip.loadAsync(buffer);
|
|
41
|
+
|
|
42
|
+
const files = {};
|
|
43
|
+
await Promise.all(
|
|
44
|
+
Object.values(zip.files)
|
|
45
|
+
.filter((f) => !f.dir)
|
|
46
|
+
.map(async (f) => { files[f.name] = await f.async('nodebuffer'); }),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Pick the primary Lottie JSON: prefer `animation.json`, else first non-metadata JSON.
|
|
50
|
+
const jsonNames = Object.keys(files).filter(
|
|
51
|
+
(n) => n.toLowerCase().endsWith('.json') &&
|
|
52
|
+
!n.endsWith('.overridden_metadata') &&
|
|
53
|
+
!n.endsWith('.trust_token'),
|
|
54
|
+
);
|
|
55
|
+
if (jsonNames.length === 0) {
|
|
56
|
+
throw new Error('No Lottie JSON found in archive.');
|
|
57
|
+
}
|
|
58
|
+
const jsonPath = jsonNames.find((n) => n.endsWith('animation.json')) ?? jsonNames[0];
|
|
59
|
+
|
|
60
|
+
let animation;
|
|
61
|
+
try {
|
|
62
|
+
animation = JSON.parse(files[jsonPath].toString('utf8'));
|
|
63
|
+
} catch (err) {
|
|
64
|
+
throw new Error(`Could not parse "${jsonPath}" as JSON: ${err.message}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const trustTokenPath = `${jsonPath}.trust_token`;
|
|
68
|
+
const trustToken = files[trustTokenPath]
|
|
69
|
+
? decodeTrustToken(files[trustTokenPath].toString('utf8').trim())
|
|
70
|
+
: null;
|
|
71
|
+
|
|
72
|
+
const metadataPath = `${jsonPath}.overridden_metadata`;
|
|
73
|
+
let metadata = null;
|
|
74
|
+
if (files[metadataPath]) {
|
|
75
|
+
try { metadata = JSON.parse(files[metadataPath].toString('utf8')); }
|
|
76
|
+
catch { /* leave metadata null if it's not parseable */ }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { files, jsonPath, animation, trustToken, trustTokenPath, metadata, metadataPath };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Human-friendly summary of a `.was` — useful for CLI inspection.
|
|
84
|
+
*/
|
|
85
|
+
export async function inspectWAS(buffer) {
|
|
86
|
+
const { jsonPath, animation, trustToken, metadata, files } = await extractFromWAS(buffer);
|
|
87
|
+
|
|
88
|
+
const jsonBytes = files[jsonPath];
|
|
89
|
+
const actualSha = createHash('sha256').update(jsonBytes).digest('hex');
|
|
90
|
+
const claimedSha = trustToken?.claimedShaHex ?? null;
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
jsonPath,
|
|
94
|
+
animation: {
|
|
95
|
+
nm: animation.nm ?? null,
|
|
96
|
+
version: animation.v ?? null,
|
|
97
|
+
width: animation.w ?? null,
|
|
98
|
+
height: animation.h ?? null,
|
|
99
|
+
fps: animation.fr ?? null,
|
|
100
|
+
durationFrames: (animation.op ?? 0) - (animation.ip ?? 0),
|
|
101
|
+
layers: Array.isArray(animation.layers) ? animation.layers.length : 0,
|
|
102
|
+
assets: Array.isArray(animation.assets) ? animation.assets.length : 0,
|
|
103
|
+
},
|
|
104
|
+
metadata,
|
|
105
|
+
trustToken: trustToken && {
|
|
106
|
+
kid: trustToken.header?.kid ?? null,
|
|
107
|
+
alg: trustToken.header?.alg ?? null,
|
|
108
|
+
stickerFileType: trustToken.payload?.sticker_file_type ?? null,
|
|
109
|
+
trustedOrigin: trustToken.payload?.sticker_file_trusted_origin ?? null,
|
|
110
|
+
claimedSha,
|
|
111
|
+
},
|
|
112
|
+
sha256: actualSha,
|
|
113
|
+
shaMatches: claimedSha !== null && claimedSha === actualSha,
|
|
114
|
+
size: jsonBytes.length,
|
|
115
|
+
fileNames: Object.keys(files),
|
|
116
|
+
};
|
|
117
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,235 +1,22 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir, stat, readdir } from 'node:fs/promises';
|
|
2
|
-
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import JSZip from 'jszip';
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
SUPPORTED_MIMES,
|
|
8
|
-
detectMimeFromBuffer,
|
|
9
|
-
detectMimeFromExtension,
|
|
10
|
-
} from './mime.js';
|
|
11
|
-
|
|
12
|
-
const TEMPLATES_DIR = fileURLToPath(new URL('../templates/', import.meta.url));
|
|
13
|
-
const BUNDLED_TEMPLATE_NAMES = Object.freeze(['pulse']);
|
|
14
|
-
|
|
15
1
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* @returns {string} absolute path to the template's JSON file
|
|
19
|
-
*/
|
|
20
|
-
export function bundledTemplate(name = 'pulse') {
|
|
21
|
-
if (!BUNDLED_TEMPLATE_NAMES.includes(name)) {
|
|
22
|
-
throw new Error(
|
|
23
|
-
`Unknown bundled template "${name}". Available: ${BUNDLED_TEMPLATE_NAMES.join(', ')}.`,
|
|
24
|
-
);
|
|
25
|
-
}
|
|
26
|
-
return join(TEMPLATES_DIR, name, 'animation.json');
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const DEFAULT_JSON_ENTRY = 'animation/animation.json';
|
|
30
|
-
|
|
31
|
-
const toDataUri = (buffer, mime) =>
|
|
32
|
-
`data:${mime};base64,${buffer.toString('base64')}`;
|
|
33
|
-
|
|
34
|
-
async function resolveImage(image) {
|
|
35
|
-
if (image == null) throw new Error('image is required.');
|
|
36
|
-
|
|
37
|
-
let buffer;
|
|
38
|
-
let mime;
|
|
39
|
-
let path;
|
|
40
|
-
|
|
41
|
-
if (typeof image === 'string') {
|
|
42
|
-
path = image;
|
|
43
|
-
} else if (Buffer.isBuffer(image)) {
|
|
44
|
-
buffer = image;
|
|
45
|
-
} else if (typeof image === 'object') {
|
|
46
|
-
({ buffer, mime, path } = image);
|
|
47
|
-
} else {
|
|
48
|
-
throw new Error('image must be a string path, Buffer, or { buffer | path, mime? }.');
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
if (!buffer) {
|
|
52
|
-
if (!path) throw new Error('image must include `buffer` or `path`.');
|
|
53
|
-
buffer = await readFile(path);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
mime ??= detectMimeFromExtension(path) ?? detectMimeFromBuffer(buffer);
|
|
57
|
-
if (!mime) {
|
|
58
|
-
throw new Error(
|
|
59
|
-
`Unable to detect image mime. Supported: ${SUPPORTED_MIMES.join(', ')}.`,
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
if (!SUPPORTED_MIMES.includes(mime)) {
|
|
63
|
-
throw new Error(`Unsupported mime "${mime}". Use one of: ${SUPPORTED_MIMES.join(', ')}.`);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return { buffer, mime };
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
async function walkFiles(root) {
|
|
70
|
-
const out = [];
|
|
71
|
-
async function visit(dir) {
|
|
72
|
-
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
73
|
-
const full = join(dir, entry.name);
|
|
74
|
-
if (entry.isDirectory()) await visit(full);
|
|
75
|
-
else if (entry.isFile()) out.push(full);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
await visit(root);
|
|
79
|
-
return out.map((abs) => ({ abs, rel: relative(root, abs).split(sep).join('/') }));
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
async function resolveTemplate(template, jsonEntryName) {
|
|
83
|
-
if (template == null) {
|
|
84
|
-
throw new Error('template is required (folder path, JSON file path, Buffer, or parsed object).');
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
if (typeof template === 'string') {
|
|
88
|
-
const info = await stat(template);
|
|
89
|
-
if (info.isDirectory()) {
|
|
90
|
-
const files = await walkFiles(template);
|
|
91
|
-
const candidate =
|
|
92
|
-
files.find((f) => f.rel === jsonEntryName) ??
|
|
93
|
-
files.find((f) => f.rel.toLowerCase().endsWith('.json'));
|
|
94
|
-
if (!candidate) {
|
|
95
|
-
throw new Error(`No JSON file found in template folder "${template}".`);
|
|
96
|
-
}
|
|
97
|
-
const lottie = JSON.parse(await readFile(candidate.abs, 'utf8'));
|
|
98
|
-
const extras = await Promise.all(
|
|
99
|
-
files
|
|
100
|
-
.filter((f) => f.abs !== candidate.abs)
|
|
101
|
-
.map(async (f) => [f.rel, await readFile(f.abs)]),
|
|
102
|
-
);
|
|
103
|
-
return {
|
|
104
|
-
lottie,
|
|
105
|
-
jsonEntryName: candidate.rel,
|
|
106
|
-
extraFiles: Object.fromEntries(extras),
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
return {
|
|
110
|
-
lottie: JSON.parse(await readFile(template, 'utf8')),
|
|
111
|
-
jsonEntryName: jsonEntryName ?? DEFAULT_JSON_ENTRY,
|
|
112
|
-
extraFiles: {},
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (Buffer.isBuffer(template)) {
|
|
117
|
-
return {
|
|
118
|
-
lottie: JSON.parse(template.toString('utf8')),
|
|
119
|
-
jsonEntryName: jsonEntryName ?? DEFAULT_JSON_ENTRY,
|
|
120
|
-
extraFiles: {},
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (typeof template === 'object') {
|
|
125
|
-
return {
|
|
126
|
-
lottie: structuredClone(template),
|
|
127
|
-
jsonEntryName: jsonEntryName ?? DEFAULT_JSON_ENTRY,
|
|
128
|
-
extraFiles: {},
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
throw new Error('template must be a path, Buffer, or parsed Lottie object.');
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function findImageAsset(lottie, selector) {
|
|
136
|
-
if (!lottie || !Array.isArray(lottie.assets) || lottie.assets.length === 0) {
|
|
137
|
-
throw new Error('Lottie template has no `assets` array.');
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const isImageAsset = (a) => typeof a?.p === 'string' && a.p.startsWith('data:image/');
|
|
141
|
-
|
|
142
|
-
if (selector == null) {
|
|
143
|
-
const found = lottie.assets.find(isImageAsset);
|
|
144
|
-
if (!found) {
|
|
145
|
-
throw new Error(
|
|
146
|
-
'No embedded base64 image asset found. Pass assetSelector to target a specific asset.',
|
|
147
|
-
);
|
|
148
|
-
}
|
|
149
|
-
return found;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (typeof selector === 'number') {
|
|
153
|
-
const found = lottie.assets[selector];
|
|
154
|
-
if (!found) throw new Error(`No asset at index ${selector}.`);
|
|
155
|
-
return found;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
if (typeof selector === 'string') {
|
|
159
|
-
const found = lottie.assets.find((a) => a?.id === selector);
|
|
160
|
-
if (!found) throw new Error(`No asset with id "${selector}".`);
|
|
161
|
-
return found;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (typeof selector === 'function') {
|
|
165
|
-
const found = lottie.assets.find(selector);
|
|
166
|
-
if (!found) throw new Error('assetSelector did not match any asset.');
|
|
167
|
-
return found;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
throw new Error('assetSelector must be a number, string, function, or undefined.');
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Build a WhatsApp animated sticker (`.was`) from an image and a Lottie template.
|
|
2
|
+
* was-sticker — re-brand a Meta WhatsApp Lottie sticker (`.was`) and ship it
|
|
3
|
+
* through Baileys as a real `lottieStickerMessage` so it renders on mobile.
|
|
175
4
|
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
* @param {Record<string, Buffer | string>} [options.extraFiles]
|
|
189
|
-
* Additional files to include in the archive (merged on top of folder-template files).
|
|
190
|
-
* @returns {Promise<{ buffer: Buffer, output?: string, mime: 'application/was' }>}
|
|
5
|
+
* Tech notes captured during the v2 rewrite:
|
|
6
|
+
*
|
|
7
|
+
* - The animation JSON inside a `.was` is signed by Meta. A JWT ES256
|
|
8
|
+
* `trust_token` (kid=196) carries the SHA-256 of the JSON. Any byte change
|
|
9
|
+
* to the JSON invalidates the token → the WhatsApp client silently drops
|
|
10
|
+
* the sticker. We never touch the animation; only `overridden_metadata`
|
|
11
|
+
* (pack name, publisher, emojis, group link) is fair game.
|
|
12
|
+
* - Baileys' `sock.sendMessage({ sticker, mimetype: 'application/was' })`
|
|
13
|
+
* emits `stickerMessage` (proto field 26). WhatsApp Web renders that;
|
|
14
|
+
* mobile WhatsApp does NOT. Mobile requires `lottieStickerMessage`
|
|
15
|
+
* (FutureProofMessage at field 74) wrapping a `stickerMessage`. The
|
|
16
|
+
* `sendLottieSticker` helper does that for you.
|
|
191
17
|
*/
|
|
192
|
-
export async function buildLottieSticker({
|
|
193
|
-
image,
|
|
194
|
-
template,
|
|
195
|
-
output,
|
|
196
|
-
assetSelector,
|
|
197
|
-
jsonEntryName,
|
|
198
|
-
extraFiles,
|
|
199
|
-
} = {}) {
|
|
200
|
-
const [{ buffer: imageBuffer, mime }, resolved] = await Promise.all([
|
|
201
|
-
resolveImage(image),
|
|
202
|
-
resolveTemplate(template, jsonEntryName),
|
|
203
|
-
]);
|
|
204
|
-
|
|
205
|
-
const asset = findImageAsset(resolved.lottie, assetSelector);
|
|
206
|
-
asset.p = toDataUri(imageBuffer, mime);
|
|
207
|
-
if ('u' in asset) asset.u = '';
|
|
208
|
-
|
|
209
|
-
const zip = new JSZip();
|
|
210
|
-
for (const [name, contents] of Object.entries(resolved.extraFiles)) {
|
|
211
|
-
zip.file(name, contents);
|
|
212
|
-
}
|
|
213
|
-
zip.file(resolved.jsonEntryName, JSON.stringify(resolved.lottie));
|
|
214
|
-
if (extraFiles) {
|
|
215
|
-
for (const [name, contents] of Object.entries(extraFiles)) {
|
|
216
|
-
zip.file(name, contents);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const wasBuffer = await zip.generateAsync({
|
|
221
|
-
type: 'nodebuffer',
|
|
222
|
-
compression: 'DEFLATE',
|
|
223
|
-
compressionOptions: { level: 6 },
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
if (output) {
|
|
227
|
-
await mkdir(dirname(resolve(output)), { recursive: true });
|
|
228
|
-
await writeFile(output, wasBuffer);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
return { buffer: wasBuffer, output, mime: 'application/was' };
|
|
232
|
-
}
|
|
233
18
|
|
|
234
|
-
export {
|
|
235
|
-
export {
|
|
19
|
+
export { extractFromWAS, inspectWAS } from './extract.js';
|
|
20
|
+
export { customizeMetadata } from './customize.js';
|
|
21
|
+
export { sendLottieSticker } from './send.js';
|
|
22
|
+
export { captureNextLottieSticker } from './capture.js';
|
package/src/send.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Send a `.was` Lottie sticker as a real `lottieStickerMessage` (FutureProofMessage
|
|
3
|
+
* at field 74). Required for mobile WhatsApp clients to render it — they silently
|
|
4
|
+
* drop Lottie payloads sent inside a plain `stickerMessage` (field 26), which is
|
|
5
|
+
* what Baileys' `sock.sendMessage({ sticker, mimetype })` emits.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
async function loadBaileys() {
|
|
9
|
+
// Imported lazily so the lib doesn't hard-require Baileys for users who only
|
|
10
|
+
// do extract/customize. Baileys is declared as an optional peer dependency.
|
|
11
|
+
try {
|
|
12
|
+
return await import('@whiskeysockets/baileys');
|
|
13
|
+
} catch (err) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
'sendLottieSticker requires "@whiskeysockets/baileys" — install it as a peer dependency.',
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Relay a `.was` buffer as a Lottie sticker on the given Baileys socket.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} sock — a connected Baileys socket
|
|
24
|
+
* @param {string} jid — recipient JID (e.g. `5511…@s.whatsapp.net` or `…@g.us`)
|
|
25
|
+
* @param {Buffer|Uint8Array} buffer — `.was` archive bytes
|
|
26
|
+
* @param {{
|
|
27
|
+
* width?: number,
|
|
28
|
+
* height?: number,
|
|
29
|
+
* accessibilityLabel?: string,
|
|
30
|
+
* messageId?: string,
|
|
31
|
+
* quoted?: object,
|
|
32
|
+
* }} [opts]
|
|
33
|
+
* @returns {Promise<{ messageId: string, fileLength: number }>}
|
|
34
|
+
*/
|
|
35
|
+
export async function sendLottieSticker(sock, jid, buffer, opts = {}) {
|
|
36
|
+
if (!sock || typeof sock.relayMessage !== 'function') {
|
|
37
|
+
throw new Error('sendLottieSticker: first argument must be a Baileys socket.');
|
|
38
|
+
}
|
|
39
|
+
if (typeof jid !== 'string' || !jid.includes('@')) {
|
|
40
|
+
throw new Error('sendLottieSticker: jid must be a JID string.');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const baileys = await loadBaileys();
|
|
44
|
+
const { prepareWAMessageMedia, generateWAMessageFromContent, generateMessageIDV2 } = baileys;
|
|
45
|
+
|
|
46
|
+
// 1. Upload — gives us url/directPath/mediaKey/fileSha256/etc.
|
|
47
|
+
const prepared = await prepareWAMessageMedia(
|
|
48
|
+
{ sticker: Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer), mimetype: 'application/was' },
|
|
49
|
+
{ upload: sock.waUploadToServer, mediaTypeOverride: 'sticker' },
|
|
50
|
+
);
|
|
51
|
+
const inner = prepared.stickerMessage;
|
|
52
|
+
if (!inner) throw new Error('prepareWAMessageMedia did not return a stickerMessage.');
|
|
53
|
+
|
|
54
|
+
inner.mimetype = 'application/was';
|
|
55
|
+
inner.isAnimated = true;
|
|
56
|
+
inner.isLottie = true;
|
|
57
|
+
if (opts.width) inner.width = opts.width;
|
|
58
|
+
if (opts.height) inner.height = opts.height;
|
|
59
|
+
if (opts.accessibilityLabel) inner.accessibilityLabel = opts.accessibilityLabel;
|
|
60
|
+
|
|
61
|
+
// 2. Wrap in lottieStickerMessage (FutureProofMessage → Message → stickerMessage).
|
|
62
|
+
const content = {
|
|
63
|
+
lottieStickerMessage: { message: { stickerMessage: inner } },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// 3. Build the WAMessage and relay it.
|
|
67
|
+
const messageId = opts.messageId ?? generateMessageIDV2();
|
|
68
|
+
const waMsg = generateWAMessageFromContent(jid, content, {
|
|
69
|
+
userJid: sock.user?.id,
|
|
70
|
+
messageId,
|
|
71
|
+
quoted: opts.quoted,
|
|
72
|
+
});
|
|
73
|
+
await sock.relayMessage(jid, waMsg.message, { messageId });
|
|
74
|
+
|
|
75
|
+
return { messageId, fileLength: Number(inner.fileLength ?? buffer.length ?? 0) };
|
|
76
|
+
}
|
package/src/mime.js
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { extname } from 'node:path';
|
|
2
|
-
|
|
3
|
-
const MIME_BY_EXT = Object.freeze({
|
|
4
|
-
'.png': 'image/png',
|
|
5
|
-
'.jpg': 'image/jpeg',
|
|
6
|
-
'.jpeg': 'image/jpeg',
|
|
7
|
-
'.webp': 'image/webp',
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
const MAGIC = [
|
|
11
|
-
{ mime: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
|
|
12
|
-
{ mime: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] },
|
|
13
|
-
{ mime: 'image/webp', bytes: [0x52, 0x49, 0x46, 0x46], suffixAt: 8, suffix: [0x57, 0x45, 0x42, 0x50] },
|
|
14
|
-
];
|
|
15
|
-
|
|
16
|
-
export const SUPPORTED_MIMES = Object.freeze([...new Set(Object.values(MIME_BY_EXT))]);
|
|
17
|
-
|
|
18
|
-
export function detectMimeFromExtension(filePath) {
|
|
19
|
-
if (!filePath) return null;
|
|
20
|
-
return MIME_BY_EXT[extname(String(filePath)).toLowerCase()] ?? null;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function detectMimeFromBuffer(buffer) {
|
|
24
|
-
if (!Buffer.isBuffer(buffer) || buffer.length < 12) return null;
|
|
25
|
-
|
|
26
|
-
for (const { mime, bytes, suffixAt, suffix } of MAGIC) {
|
|
27
|
-
const headOk = bytes.every((b, i) => buffer[i] === b);
|
|
28
|
-
if (!headOk) continue;
|
|
29
|
-
if (suffix && !suffix.every((b, i) => buffer[suffixAt + i] === b)) continue;
|
|
30
|
-
return mime;
|
|
31
|
-
}
|
|
32
|
-
return null;
|
|
33
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"v":"5.7.1","fr":30,"ip":0,"op":30,"w":512,"h":512,"nm":"pulse","ddd":0,"assets":[{"id":"image_0","w":512,"h":512,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAfbLI3wAAAABJRU5ErkJggg=="}],"layers":[{"ddd":0,"ind":1,"ty":2,"nm":"image","refId":"image_0","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":0,"k":[256,256,0]},"a":{"a":0,"k":[256,256,0]},"s":{"a":1,"k":[{"i":{"x":[0.5,0.5,0.5],"y":[1,1,1]},"o":{"x":[0.5,0.5,0.5],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.5,0.5,0.5],"y":[1,1,1]},"o":{"x":[0.5,0.5,0.5],"y":[0,0,0]},"t":15,"s":[115,115,100]},{"t":30,"s":[100,100,100]}]}},"ao":0,"ip":0,"op":30,"st":0,"bm":0}],"markers":[]}
|