swan-api 1.0.1 → 1.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/README.md CHANGED
@@ -53,7 +53,7 @@ socket.on("newMessage", async (msg) => {
53
53
  if (msg.fromMe)
54
54
  return;
55
55
 
56
- console.log(`Message from ${msg.author}: ${msg.text}`);
56
+ console.log(`New Message from ${msg.fromName}`);
57
57
 
58
58
  await msg.reply("Processing your message...");
59
59
 
@@ -168,13 +168,58 @@ const media = await MessageMedia.fromUrl("https://example.com/image.png");
168
168
 
169
169
  ---
170
170
 
171
+ ### Sticker
172
+ Converts an image or video buffer into a WhatsApp-ready sticker (WebP, 512x512, with embedded pack/author metadata).
173
+
174
+ You normally don't need to call `Sticker` directly — just use `msg.reply(media, {asSticker: true})`, which handles quality adjustment automatically to stay under WhatsApp's size limit. `Sticker` is exported in case you want to generate the WebP buffer yourself.
175
+
176
+ #### Options
177
+ | Option | Type | Default | Description |
178
+ | ------ | ---- | ------- | ----------- |
179
+ | `stickerAuthor` | `string` | `""` | Sticker pack publisher name. |
180
+ | `stickerPack` | `string` | `""` | Sticker pack name. |
181
+ | `stickerType` | `string` | `"default"` | Resize/crop mode. See table below. |
182
+ | `stickerQuality` | `number` | `80` | WebP encoding quality (0-100). |
183
+
184
+ #### Sticker types
185
+ | Type | Behavior |
186
+ | ---- | -------- |
187
+ | `default` | Stretches the image to fill 512x512, ignoring original proportions. |
188
+ | `full` | Keeps original proportions, padding the rest with transparency. |
189
+ | `crop` | Keeps original proportions, cropping the excess to fill 512x512. |
190
+ | `circle` | Same as `crop`, masked into a circle. |
191
+ | `rounded` | Same as `crop`, masked with rounded corners. |
192
+
193
+ Videos are automatically converted to animated stickers.
194
+
195
+ #### Usage
196
+ ```javascript
197
+ import { Socket, Sticker } from "swan-api";
198
+ const socket = new Socket();
199
+
200
+ socket.on("newMessage", async (msg) => {
201
+ const media = await msg.downloadMedia();
202
+ if (!media)
203
+ return;
204
+
205
+ // send directly as a sticker
206
+ await msg.reply(media, {
207
+ asSticker: true,
208
+ stickerPack: "Bot Pack",
209
+ stickerAuthor: "Bot"
210
+ });
211
+ });
212
+ ```
213
+ ---
214
+
171
215
  ## Exports
172
216
 
173
217
  ```javascript
174
218
  import {
175
219
  Socket,
176
220
  Message,
177
- MessageMedia
221
+ MessageMedia,
222
+ Sticker
178
223
  } from "swan-api";
179
224
  ```
180
225
 
package/Socket.js CHANGED
@@ -3,8 +3,11 @@ import makeWASocket, { useMultiFileAuthState, fetchLatestBaileysVersion, Disconn
3
3
  import P from 'pino'
4
4
  import qrcode from 'qrcode-terminal'
5
5
  import { EventEmitter } from "node:events";
6
- import { Sticker } from 'wa-sticker-formatter'
7
6
 
7
+ import fs from "node:fs/promises";
8
+
9
+
10
+ import Sticker from './Sticker.js'
8
11
  import Message from './Message.js';
9
12
  import MessageMedia from './MessageMedia.js';
10
13
 
@@ -51,7 +54,6 @@ export default class Socket extends EventEmitter {
51
54
  const logger = P({level: 'silent'})
52
55
 
53
56
  this.socket = makeWASocket({version, auth: state, logger});
54
- this.userId = this.socket.user.id;
55
57
  this.socket.ev.on('creds.update', saveCreds)
56
58
  }
57
59
 
@@ -84,7 +86,8 @@ export default class Socket extends EventEmitter {
84
86
  if (shouldReconnect)
85
87
  await this.connect()
86
88
  } else if (connection == 'open'){
87
- soketReady = true
89
+ socketReady = true
90
+ this.userId = this.socket.user.id;
88
91
  this.emit('ready');
89
92
  }
90
93
  });
@@ -107,32 +110,26 @@ export default class Socket extends EventEmitter {
107
110
 
108
111
  // parse media for sticker creation
109
112
  if (opts.asSticker){
110
- const isVideo = mimetype?.startsWith('video') || mimetype === 'image/gif'
111
- // video proportion must be 'full' to not get corrupted frames
112
- if (isVideo)
113
- opts.stickerType = 'full'
113
+ // WhatsApp limits: 100KB for static stickers, 500KB for animated ones
114
+ const isAnimated = mimetype.startsWith("video/");
115
+ const maxBytes = (isAnimated ? 500 : 100) * 1024;
114
116
 
115
117
  // tries to create the sticker at the highest quality,
116
118
  // if the resulting file exceeds WhatsApp's 1MB limit
117
119
  // the quality is reduced by 10% and retried
118
120
  // throws an exception if quality goes below 0%
119
- let final_quality = 1;
120
- while (final_quality >= 0) {
121
- const sticker = new Sticker(buffer, {
122
- pack: opts.stickerPack,
123
- author: opts.stickerAuthor,
124
- type: opts.stickerType,
125
- quality: final_quality,
126
- });
127
-
128
- const webp = await sticker.toBuffer();
129
- const size = (webp.length/1024)/1024;
130
- if (size >= 1)
131
- final_quality -= 0.1;
121
+ let final_quality = 100;
122
+ while (final_quality > 0) {
123
+ const webp = await Sticker(buffer, {...opts,
124
+ stickerQuality : final_quality})
125
+
126
+ if (webp.length > maxBytes)
127
+ final_quality -= 10;
132
128
  else
133
129
  return await this.socket.sendMessage(jid, {sticker: webp}, opts)
134
130
  }
135
131
 
132
+ throw new Error(`SWAN send(): could not compress sticker below ${maxBytes / 1024}KB`);
136
133
  }
137
134
 
138
135
  //send regular files
package/Sticker.js ADDED
@@ -0,0 +1,125 @@
1
+ import sharp from "sharp";
2
+ import ffmpegPath from "ffmpeg-static";
3
+ import { execa } from "execa";
4
+ import { fileTypeFromBuffer } from "file-type";
5
+
6
+ export default async function Sticker(buffer, opts = {}) {
7
+ const author = opts.stickerAuthor ? opts.stickerAuthor : "";
8
+ const pack = opts.stickerPack ? opts.stickerPack : "";
9
+ const type = opts.stickerType ? opts.stickerType : "fill";
10
+ const quality = opts.stickerQuality ? opts.stickerQuality : 80;
11
+
12
+ const info = await fileTypeFromBuffer(buffer);
13
+ if (!info)
14
+ throw Error("Unknown file type");
15
+
16
+ if (info.mime.startsWith("video/"))
17
+ buffer = await convertVideo(buffer);
18
+ else if (!info.mime.startsWith("image/"))
19
+ throw Error(`Unsupported media type: ${info.mime}`);
20
+
21
+ const fit = type === "full" ? "contain" :
22
+ type === "fill" ? "fill" : "cover"; // crop, circle, rounded
23
+
24
+ let image = sharp(buffer).resize(512, 512, {fit,
25
+ background: { r: 0, g: 0, b: 0, alpha: 0 }}).ensureAlpha();
26
+
27
+ if (type === "circle")
28
+ image = image.composite([{
29
+ input: Buffer.from(`<svg width="512" height="512">
30
+ <circle cx="256" cy="256" r="256" fill="white"/></svg>`),
31
+ blend: "dest-in"
32
+ }]);
33
+
34
+ if (type === "rounded")
35
+ image = image.composite([{
36
+ blend: "dest-in",
37
+ input: Buffer.from(`<svg width="512" height="512">
38
+ <rect x="0" y="0" width="512" height="512"
39
+ rx="64" ry="64" fill="white"/></svg>`),
40
+ }]);
41
+
42
+ const webp = await image.webp({ quality }).toBuffer();
43
+ return addExif(webp, author, pack)
44
+ }
45
+
46
+ // for animated stickers
47
+ async function convertVideo(buffer) {
48
+ const { stdout } = await execa(ffmpegPath, [
49
+ "-i", "pipe:0", "-vcodec", "libwebp", "-vf",
50
+ "scale=512:512:force_original_aspect_ratio=decrease",
51
+ "-loop", "0", "-an", "-f", "webp", "pipe:1"
52
+ ], { input: buffer, encoding: null });
53
+
54
+ return stdout;
55
+ }
56
+
57
+ // Add metadata
58
+ function addExif(webp, author, pack) {
59
+ const json = Buffer.from(JSON.stringify({
60
+ "sticker-pack-id": "com.swan.sticker",
61
+ "sticker-pack-name": pack,
62
+ "sticker-pack-publisher": author,
63
+ "emojis": []
64
+ }));
65
+
66
+ const tiff = Buffer.alloc(22 + json.length);
67
+ tiff.write("II", 0);
68
+ tiff.writeUInt16LE(42, 2);
69
+ tiff.writeUInt32LE(8, 4);
70
+ tiff.writeUInt16LE(1, 8);
71
+ tiff.writeUInt16LE(0x5741, 10);
72
+ tiff.writeUInt16LE(7, 12);
73
+ tiff.writeUInt32LE(json.length, 14);
74
+ tiff.writeUInt32LE(22, 18);
75
+ json.copy(tiff, 22);
76
+
77
+ let exif = Buffer.concat([
78
+ Buffer.from("EXIF"),
79
+ Buffer.alloc(4),
80
+ tiff
81
+ ]);
82
+ exif.writeUInt32LE(tiff.length, 4);
83
+ if (exif.length % 2)
84
+ exif = Buffer.concat([exif, Buffer.from([0])]);
85
+
86
+ // EXIF flag in VP8X
87
+ let body = Buffer.from(webp.subarray(12));
88
+ body = ensureVP8X(body);
89
+ if (body.subarray(0, 4).toString("ascii") === "VP8X")
90
+ body[8] |= 0x08;
91
+
92
+ const riffSize = Buffer.alloc(4);
93
+ riffSize.writeUInt32LE(4 + body.length + exif.length); // +4 = "WEBP"
94
+
95
+ return Buffer.concat([
96
+ webp.subarray(0, 4), // "RIFF"
97
+ riffSize,
98
+ webp.subarray(8, 12), // "WEBP"
99
+ body, // image data
100
+ exif // EXIF
101
+ ]);
102
+ }
103
+
104
+ // Adds VP8X container if sharp didn't generated it
105
+ function ensureVP8X(body) {
106
+ const fourcc = body.subarray(0, 4).toString("ascii");
107
+ if (fourcc === "VP8X") return body;
108
+ if (fourcc !== "VP8 " && fourcc !== "VP8L") return body;
109
+
110
+ const width = 512, height = 512; // always 512x512
111
+
112
+ const vp8x = Buffer.alloc(18);
113
+ vp8x.write("VP8X", 0);
114
+ vp8x.writeUInt32LE(10, 4); // payload size
115
+
116
+ let flags = 0;
117
+ if (fourcc === "VP8L") flags |= 0x10; // VP8L supports alpha
118
+ vp8x[8] = flags;
119
+ // bytes 9-11 (reserved) 0 by alloc
120
+
121
+ vp8x.writeUIntLE(width - 1, 12, 3);
122
+ vp8x.writeUIntLE(height - 1, 15, 3);
123
+
124
+ return Buffer.concat([vp8x, body]);
125
+ }
package/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { default as Socket } from './Socket.js';
2
2
  export { default as Message } from './Message.js';
3
3
  export { default as MessageMedia } from './MessageMedia.js';
4
+ export { default as Sticker } from './Sticker.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swan-api",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Simple WhatsApp API for Node",
5
5
  "keywords": [
6
6
  "whatsapp",
@@ -30,6 +30,7 @@
30
30
  "Socket.js",
31
31
  "Message.js",
32
32
  "MessageMedia.js",
33
+ "Sticker.js",
33
34
  "README.md",
34
35
  "LICENSE"
35
36
  ],
@@ -37,11 +38,18 @@
37
38
  "node": ">=18"
38
39
  },
39
40
  "dependencies": {
40
- "@whiskeysockets/baileys": "^6.7.18",
41
- "axios": "^1.11.0",
42
- "pino": "^9.7.0",
43
- "mime-types": "^3.0.1",
41
+ "@whiskeysockets/baileys": "^7.0.0-rc13",
44
42
  "qrcode-terminal": "^0.12.0",
45
- "wa-sticker-formatter": "^4.4.4"
43
+ "pino": "^10.3.1",
44
+ "axios": "^1.18.1",
45
+ "mime-types": "^3.0.2",
46
+ "ffmpeg-static": "^5.2.0",
47
+ "execa": "^9.6.0"
48
+ },
49
+ "allowScripts": {
50
+ "@whiskeysockets/baileys@7.0.0-rc13": true,
51
+ "protobufjs@7.6.5": true,
52
+ "sharp@0.30.7": true,
53
+ "ffmpeg-static@5.3.0": true
46
54
  }
47
55
  }