swan-api 1.0.0 → 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/Message.js CHANGED
@@ -31,7 +31,7 @@ export default class Message {
31
31
  const ctx = m.extendedTextMessage?.contextInfo;
32
32
  if (ctx?.quotedMessage){
33
33
  this.quoted = new Message(socket, {
34
- key: {remoteJid: this.fromId, fromMe: ctx.participant === socket.user.id},
34
+ key: {remoteJid: this.fromId, fromMe: ctx.participant === socket.userId},
35
35
  message: ctx.quotedMessage
36
36
  });
37
37
  }
@@ -52,7 +52,7 @@ export default class Message {
52
52
 
53
53
  // returns the sender's name and phone number
54
54
  async getContact() {
55
- const jid = this.fromMe ? this.socket.user.id : this.author;
55
+ const jid = this.fromMe ? this.socket.userId : this.author;
56
56
  const number = jid.split('@')[0];
57
57
  return {name: this.fromName, number}
58
58
  }
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,20 +3,45 @@ 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
 
11
14
  //Wrapper Class For Baileys Socket
12
15
  export default class Socket extends EventEmitter {
13
- // creates a socket instance
14
- constructor() {
16
+ constructor(){
15
17
  super();
16
- this.connect();
18
+ this.muteSessionErrors()
19
+ this.connect()
20
+ }
21
+
22
+ // hide internal session errors that does not affect the use
23
+ muteSessionErrors(){
24
+ const oldLog = console.log;
25
+ console.log = (...args) => {
26
+ if (String(args[0]).includes("Closing session"))
27
+ return;
28
+
29
+ oldLog(...args);
30
+ };
31
+
32
+ const oldError = console.error;
33
+ console.error = (...args) => {
34
+ if (
35
+ String(args[0]).includes("Failed to decrypt") ||
36
+ String(args[0]).includes("Session error")
37
+ )
38
+ return;
39
+
40
+ oldError(...args);
41
+ };
17
42
  }
18
43
 
19
- // tries to authenticate and bind default events
44
+ // creates a Socket instance and tries to authenticate
20
45
  async connect() {
21
46
  await this.authenticate();
22
47
  this.bindEvents();
@@ -26,16 +51,21 @@ export default class Socket extends EventEmitter {
26
51
  async authenticate(){
27
52
  const { state, saveCreds } = await useMultiFileAuthState('auth')
28
53
  const { version } = await fetchLatestBaileysVersion()
54
+ const logger = P({level: 'silent'})
29
55
 
30
- this.socket = makeWASocket({version, auth: state, logger: P({level: 'silent'})});
31
- this.user = this.socket.user;
56
+ this.socket = makeWASocket({version, auth: state, logger});
32
57
  this.socket.ev.on('creds.update', saveCreds)
33
58
  }
34
59
 
35
60
  //bind events from baileys to default treatment functions
36
61
  bindEvents(){
62
+ let socketReady = false
63
+
37
64
  //each new message emits a 'newMessage' event and a 'Message' object
38
65
  this.socket.ev.on('messages.upsert', ({messages}) => {
66
+ if (!socketReady)
67
+ return;
68
+
39
69
  for (const msg of messages){
40
70
  if (!msg.message)
41
71
  continue;
@@ -45,7 +75,7 @@ export default class Socket extends EventEmitter {
45
75
 
46
76
  //emits 'ready' when opens connection
47
77
  //on connnection lost, tries to reconnect
48
- this.socket.ev.on('connection.update', (update) => {
78
+ this.socket.ev.on('connection.update', async (update) => {
49
79
  const { connection, lastDisconnect, qr } = update;
50
80
  if (qr)
51
81
  qrcode.generate(qr, {small: true})
@@ -54,9 +84,12 @@ export default class Socket extends EventEmitter {
54
84
  const shouldReconnect = lastDisconnect?.error?.output?.statusCode
55
85
  !== DisconnectReason.loggedOut;
56
86
  if (shouldReconnect)
57
- this.connect();
58
- } else if (connection == 'open')
87
+ await this.connect()
88
+ } else if (connection == 'open'){
89
+ socketReady = true
90
+ this.userId = this.socket.user.id;
59
91
  this.emit('ready');
92
+ }
60
93
  });
61
94
  }
62
95
 
@@ -77,32 +110,26 @@ export default class Socket extends EventEmitter {
77
110
 
78
111
  // parse media for sticker creation
79
112
  if (opts.asSticker){
80
- const isVideo = mimetype?.startsWith('video') || mimetype === 'image/gif'
81
- // video proportion must be 'full' to not get corrupted frames
82
- if (isVideo)
83
- opts.stickerProportion = '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;
84
116
 
85
117
  // tries to create the sticker at the highest quality,
86
118
  // if the resulting file exceeds WhatsApp's 1MB limit
87
119
  // the quality is reduced by 10% and retried
88
120
  // throws an exception if quality goes below 0%
89
- let final_quality = 1;
90
- while (final_quality >= 0) {
91
- const sticker = new Sticker(buffer, {
92
- pack: opts.stickerName,
93
- author: opts.stickerAuthor,
94
- type: opts.stickerProportion,
95
- quality: final_quality,
96
- });
97
-
98
- const webp = await sticker.toBuffer();
99
- const size = (webp.length/1024)/1024;
100
- if (size >= 1)
101
- 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;
102
128
  else
103
129
  return await this.socket.sendMessage(jid, {sticker: webp}, opts)
104
130
  }
105
131
 
132
+ throw new Error(`SWAN send(): could not compress sticker below ${maxBytes / 1024}KB`);
106
133
  }
107
134
 
108
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.0",
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
  }