levix-bot 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/LICENSE +21 -0
- package/README.md +105 -0
- package/SETUP.md +242 -0
- package/app.cjs +337 -0
- package/bin/levix.js +7 -0
- package/deploy/install.sh +182 -0
- package/deploy/levix.service +56 -0
- package/deploy/nginx/levix.leviro.net.conf +79 -0
- package/package.json +78 -0
- package/public/brand/apple-touch-icon.png +0 -0
- package/public/brand/banner.jpg +0 -0
- package/public/brand/banner.webp +0 -0
- package/public/brand/icon-16.png +0 -0
- package/public/brand/icon-192.png +0 -0
- package/public/brand/icon-32.png +0 -0
- package/public/brand/icon-512.png +0 -0
- package/public/brand/icon-512.webp +0 -0
- package/public/brand/mark.png +0 -0
- package/public/brand/mark.svg +16 -0
- package/public/brand/mark.webp +0 -0
- package/public/brand/wordmark.png +0 -0
- package/public/brand/wordmark.webp +0 -0
- package/public/dashboard.css +881 -0
- package/public/dashboard.js +1057 -0
- package/public/logo.svg +16 -0
- package/public/qrcode.min.js +1 -0
- package/public/socket.io.min.js +7 -0
- package/scheduler.cjs +197 -0
- package/src/auth/auth-storage.cjs +74 -0
- package/src/auth/use-database-auth-state.js +130 -0
- package/src/bootstrap/core.js +74 -0
- package/src/bootstrap/events.cjs +43 -0
- package/src/bootstrap/panel.js +97 -0
- package/src/cli.js +153 -0
- package/src/commands/autoschedule.cjs +84 -0
- package/src/commands/blacklist.cjs +116 -0
- package/src/commands/block.cjs +70 -0
- package/src/commands/calc.cjs +387 -0
- package/src/commands/debt.cjs +175 -0
- package/src/commands/deleteschedule.cjs +36 -0
- package/src/commands/gemini.cjs +727 -0
- package/src/commands/group/add.cjs +136 -0
- package/src/commands/group/all.cjs +43 -0
- package/src/commands/group/antiSpam.cjs +96 -0
- package/src/commands/group/antilink.cjs +167 -0
- package/src/commands/group/approveall.cjs +54 -0
- package/src/commands/group/clearwarns.cjs +41 -0
- package/src/commands/group/deletenote.cjs +33 -0
- package/src/commands/group/demote.cjs +62 -0
- package/src/commands/group/kick.cjs +85 -0
- package/src/commands/group/media.cjs +120 -0
- package/src/commands/group/members.cjs +59 -0
- package/src/commands/group/note.cjs +32 -0
- package/src/commands/group/promote.cjs +60 -0
- package/src/commands/group/removeall.cjs +42 -0
- package/src/commands/group/rules.cjs +31 -0
- package/src/commands/group/save.cjs +36 -0
- package/src/commands/group/setname.cjs +41 -0
- package/src/commands/group/setpp.cjs +51 -0
- package/src/commands/group/setrules.cjs +46 -0
- package/src/commands/group/setwarn.cjs +58 -0
- package/src/commands/group/tagadmins.cjs +50 -0
- package/src/commands/group/warn.cjs +105 -0
- package/src/commands/group/warns.cjs +66 -0
- package/src/commands/group/welcome.cjs +109 -0
- package/src/commands/group.cjs +160 -0
- package/src/commands/help.cjs +230 -0
- package/src/commands/listschedules.cjs +47 -0
- package/src/commands/loop.cjs +163 -0
- package/src/commands/memory.cjs +201 -0
- package/src/commands/mod.cjs +155 -0
- package/src/commands/notes.cjs +28 -0
- package/src/commands/perm.cjs +236 -0
- package/src/commands/ping.cjs +17 -0
- package/src/commands/poll.cjs +80 -0
- package/src/commands/prayer.cjs +64 -0
- package/src/commands/qr.cjs +39 -0
- package/src/commands/rand.cjs +130 -0
- package/src/commands/restart.cjs +25 -0
- package/src/commands/schedule.cjs +86 -0
- package/src/commands/score.cjs +130 -0
- package/src/commands/setprefix.cjs +40 -0
- package/src/commands/shortlink.cjs +74 -0
- package/src/commands/shutdown.cjs +45 -0
- package/src/commands/status.cjs +75 -0
- package/src/commands/stt.cjs +150 -0
- package/src/commands/todo.cjs +88 -0
- package/src/commands/tts.cjs +170 -0
- package/src/commands/unblock.cjs +57 -0
- package/src/commands/weather.cjs +79 -0
- package/src/config/ai-persona.md +17 -0
- package/src/config/baileys.config.js +51 -0
- package/src/config/brand.cjs +36 -0
- package/src/config/brand.esm.js +17 -0
- package/src/config/constants.js +16 -0
- package/src/config/defaults.cjs +91 -0
- package/src/config/lock.cjs +110 -0
- package/src/config/paths.cjs +90 -0
- package/src/config/runtime-config.cjs +219 -0
- package/src/config/secrets.cjs +130 -0
- package/src/config/settings.cjs +386 -0
- package/src/core/connection.js +199 -0
- package/src/core/events.js +61 -0
- package/src/core/socket.js +64 -0
- package/src/db/db.cjs +282 -0
- package/src/db/store.cjs +837 -0
- package/src/db/store.esm.js +17 -0
- package/src/domain/caddy.js +120 -0
- package/src/domain/command.js +380 -0
- package/src/domain/detect.js +173 -0
- package/src/domain/nginx.js +138 -0
- package/src/domain/system.js +148 -0
- package/src/handlers/command.handler.js +414 -0
- package/src/handlers/group.handler.js +73 -0
- package/src/handlers/message.handler.js +149 -0
- package/src/index.js +217 -0
- package/src/middleware/antispam.middleware.js +68 -0
- package/src/middleware/blacklist.middleware.js +29 -0
- package/src/middleware/forward-tracking.middleware.js +49 -0
- package/src/middleware/permissions.middleware.js +114 -0
- package/src/routes/dashboard.api.esm.js +779 -0
- package/src/services/aiAgent.cjs +317 -0
- package/src/services/aiTools.cjs +635 -0
- package/src/utils/datetime.cjs +61 -0
- package/src/utils/lid-helper.esm.js +143 -0
- package/src/utils/logger.cjs +72 -0
- package/src/utils/memory.cjs +385 -0
- package/src/utils/normalizeJid.cjs +65 -0
- package/src/utils/normalizeJid.esm.js +37 -0
- package/src/utils/openBrowser.cjs +129 -0
- package/src/utils/permissions.cjs +154 -0
- package/src/utils/permissions.esm.js +375 -0
- package/src/utils/recentMessageCache.esm.js +70 -0
- package/src/utils/requestOrigin.cjs +102 -0
- package/src/utils/sendBotMessage.cjs +43 -0
- package/src/utils/sendBotMessage.esm.js +139 -0
- package/src/utils/statusMessage.cjs +179 -0
- package/src/utils/storage-hub.cjs +49 -0
- package/src/utils/storage-hub.esm.js +24 -0
- package/src/utils/storage.cjs +80 -0
- package/src/utils/storage.esm.js +88 -0
- package/src/utils/textDecode.cjs +197 -0
- package/src/utils/thumbnail.cjs +591 -0
- package/src/utils/typing.esm.js +102 -0
- package/views/dashboard.ejs +329 -0
- package/views/login.ejs +105 -0
- package/views/qr.ejs +108 -0
- package/views/setup.ejs +122 -0
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
// Outgoing media thumbnails (jpegThumbnail) for images and videos.
|
|
2
|
+
//
|
|
3
|
+
// Why this file exists
|
|
4
|
+
// --------------------
|
|
5
|
+
// Baileys only attaches a `jpegThumbnail` to an outgoing image/video when it
|
|
6
|
+
// can find the tools it needs:
|
|
7
|
+
// * images -> an image processing library (`sharp` or `jimp`)
|
|
8
|
+
// * videos -> an `ffmpeg` binary ON THE PATH (it shells out to `ffmpeg ...`)
|
|
9
|
+
//
|
|
10
|
+
// This project ships neither: sharp/jimp aren't dependencies, and our ffmpeg
|
|
11
|
+
// comes from the `ffmpeg-static` package — a binary inside node_modules that is
|
|
12
|
+
// NOT on the PATH. So Baileys silently gives up and every picture / clip the
|
|
13
|
+
// bot sends arrives with a blank grey preview: no thumbnail in the chat list,
|
|
14
|
+
// none in reply quotes, and nothing to look at until the media downloads.
|
|
15
|
+
//
|
|
16
|
+
// The fix is to build the thumbnail ourselves with the ffmpeg we already have
|
|
17
|
+
// and hand it to Baileys ready-made — Baileys skips its own generation when
|
|
18
|
+
// `jpegThumbnail` is already present on the content. While ffmpeg has the file
|
|
19
|
+
// open we also read the real dimensions (and a video's duration), which Baileys
|
|
20
|
+
// would otherwise leave empty and which WhatsApp uses to size the bubble
|
|
21
|
+
// before the download finishes.
|
|
22
|
+
//
|
|
23
|
+
// CommonJS on purpose: both the ESM core and the .cjs commands consume it.
|
|
24
|
+
|
|
25
|
+
const fs = require("fs");
|
|
26
|
+
const os = require("os");
|
|
27
|
+
const path = require("path");
|
|
28
|
+
const { spawn } = require("child_process");
|
|
29
|
+
|
|
30
|
+
const logger = require("./logger.cjs");
|
|
31
|
+
|
|
32
|
+
// Only used for remote sources; required lazily so a missing axios can never
|
|
33
|
+
// take the send path down.
|
|
34
|
+
function getAxios() {
|
|
35
|
+
try {
|
|
36
|
+
return require("axios");
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ffmpegStatic = (() => {
|
|
43
|
+
try {
|
|
44
|
+
return require("ffmpeg-static");
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
})();
|
|
49
|
+
|
|
50
|
+
// Read at call time so the dashboard can point at another binary without a
|
|
51
|
+
// restart; ffmpeg-static is what ships with the bot and works nearly everywhere.
|
|
52
|
+
function ffmpegBin() {
|
|
53
|
+
return settings.get("ffmpeg_path") || ffmpegStatic || "ffmpeg";
|
|
54
|
+
}
|
|
55
|
+
const FFMPEG_TIMEOUT_MS = 20000;
|
|
56
|
+
|
|
57
|
+
// WhatsApp carries the preview inline in the message envelope — keep it small.
|
|
58
|
+
// (Real clients send ~100–300px JPEGs; anything huge just bloats the stanza.)
|
|
59
|
+
const MAX_THUMB_BYTES = 60 * 1024;
|
|
60
|
+
const THUMB_STEPS = [
|
|
61
|
+
[320, 6],
|
|
62
|
+
[240, 10],
|
|
63
|
+
[160, 16],
|
|
64
|
+
[120, 22],
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
// Media handed to us as a remote URL (`{ image: { url: "https://…" } }`) used
|
|
68
|
+
// to get no preview at all, because we refused to fetch it. We now pull it
|
|
69
|
+
// down once — capped and time-boxed — so those sends look like every other.
|
|
70
|
+
// Read per call (config/settings.cjs) so the dashboard can flip these without
|
|
71
|
+
// a restart.
|
|
72
|
+
const settings = require("../config/settings.cjs");
|
|
73
|
+
const remoteThumbsEnabled = () => settings.get("thumbnail_remote");
|
|
74
|
+
const remoteMaxBytes = () => settings.get("thumbnail_remote_max_mb") * 1024 * 1024;
|
|
75
|
+
const remoteTimeoutMs = () => settings.get("thumbnail_remote_timeout_ms");
|
|
76
|
+
|
|
77
|
+
// Last-resort fallback: when ffmpeg can't run at all, a JPEG that is already
|
|
78
|
+
// small enough can simply BE its own preview. Not ideal (the full picture
|
|
79
|
+
// travels twice) but a real preview beats a grey box, and it keeps the feature
|
|
80
|
+
// working on hosts where the ffmpeg-static binary is missing or unusable.
|
|
81
|
+
const RAW_JPEG_FALLBACK_BYTES = 100 * 1024;
|
|
82
|
+
|
|
83
|
+
// Flipped to false the first time the binary turns out to be missing, so we
|
|
84
|
+
// don't pay a failed spawn on every single media message.
|
|
85
|
+
let ffmpegAvailable = true;
|
|
86
|
+
let warnedAboutFfmpeg = false;
|
|
87
|
+
|
|
88
|
+
function warnMissingFfmpeg(reason) {
|
|
89
|
+
if (warnedAboutFfmpeg) return;
|
|
90
|
+
warnedAboutFfmpeg = true;
|
|
91
|
+
logger.warn(
|
|
92
|
+
{ ffmpeg: ffmpegBin(), reason },
|
|
93
|
+
"[thumbnail] ffmpeg isn't runnable — video previews are off and images " +
|
|
94
|
+
"fall back to raw-JPEG previews. Reinstall `ffmpeg-static`, or point " +
|
|
95
|
+
"Settings -> Media -> ffmpeg path at a working binary."
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// tiny fs helpers
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
function tmpPath(ext) {
|
|
104
|
+
const rand = Math.random().toString(36).slice(2, 10);
|
|
105
|
+
return path.join(os.tmpdir(), `wa-bot-thumb-${Date.now()}-${rand}.${ext}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function safeUnlink(file) {
|
|
109
|
+
if (!file) return;
|
|
110
|
+
try {
|
|
111
|
+
fs.unlinkSync(file);
|
|
112
|
+
} catch {
|
|
113
|
+
// already gone
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function sizeOf(file) {
|
|
118
|
+
try {
|
|
119
|
+
return fs.statSync(file).size;
|
|
120
|
+
} catch {
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// ffmpeg
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Run ffmpeg and always resolve — the caller decides what a failure means.
|
|
131
|
+
* We keep stderr because that's where ffmpeg prints the stream info we parse.
|
|
132
|
+
*/
|
|
133
|
+
function runFfmpeg(args) {
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
let child;
|
|
136
|
+
try {
|
|
137
|
+
child = spawn(ffmpegBin(), ["-hide_banner", "-nostdin", ...args], {
|
|
138
|
+
windowsHide: true,
|
|
139
|
+
});
|
|
140
|
+
} catch (err) {
|
|
141
|
+
ffmpegAvailable = false;
|
|
142
|
+
warnMissingFfmpeg(err?.message);
|
|
143
|
+
return resolve({ ok: false, stderr: String(err?.message || err), stdout: "" });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let stderr = "";
|
|
147
|
+
let stdout = "";
|
|
148
|
+
const timer = setTimeout(() => {
|
|
149
|
+
try {
|
|
150
|
+
child.kill("SIGKILL");
|
|
151
|
+
} catch {
|
|
152
|
+
// already dead
|
|
153
|
+
}
|
|
154
|
+
}, FFMPEG_TIMEOUT_MS);
|
|
155
|
+
|
|
156
|
+
child.stderr?.on("data", (chunk) => {
|
|
157
|
+
// ffmpeg can be chatty; the header we care about comes first.
|
|
158
|
+
if (stderr.length < 64 * 1024) stderr += chunk.toString();
|
|
159
|
+
});
|
|
160
|
+
child.stdout?.on("data", (chunk) => {
|
|
161
|
+
if (stdout.length < 4 * 1024) stdout += chunk.toString();
|
|
162
|
+
});
|
|
163
|
+
child.on("error", (err) => {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
// ENOENT (no binary) / EACCES (not executable) / ENOEXEC (wrong libc —
|
|
166
|
+
// happens with ffmpeg-static on Alpine) are all permanent for this run.
|
|
167
|
+
if (["ENOENT", "EACCES", "ENOEXEC"].includes(err?.code)) {
|
|
168
|
+
ffmpegAvailable = false;
|
|
169
|
+
warnMissingFfmpeg(err?.code);
|
|
170
|
+
}
|
|
171
|
+
resolve({ ok: false, stderr: String(err?.message || err), stdout });
|
|
172
|
+
});
|
|
173
|
+
child.on("close", (code) => {
|
|
174
|
+
clearTimeout(timer);
|
|
175
|
+
resolve({ ok: code === 0, code, stderr, stdout });
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Is the ffmpeg binary actually runnable here? Used by `!status` so a missing
|
|
182
|
+
* or unusable binary is visible instead of silently costing every preview.
|
|
183
|
+
*
|
|
184
|
+
* @returns {Promise<{ok: boolean, path: string, version?: string, error?: string}>}
|
|
185
|
+
*/
|
|
186
|
+
async function checkFfmpeg() {
|
|
187
|
+
const result = await runFfmpeg(["-version"]);
|
|
188
|
+
if (result.ok) {
|
|
189
|
+
ffmpegAvailable = true;
|
|
190
|
+
const version = (result.stdout.split("\n")[0] || "").trim();
|
|
191
|
+
return { ok: true, path: ffmpegBin(), version };
|
|
192
|
+
}
|
|
193
|
+
return { ok: false, path: ffmpegBin(), error: result.stderr.slice(0, 200) };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Pull `1920x1080` / `Duration: 00:01:23.45` out of ffmpeg's stderr header. */
|
|
197
|
+
function parseProbe(stderr = "") {
|
|
198
|
+
const info = {
|
|
199
|
+
width: 0,
|
|
200
|
+
height: 0,
|
|
201
|
+
seconds: 0,
|
|
202
|
+
hasVideo: /Stream #\d+:\d+[^\n]*?: Video:/.test(stderr),
|
|
203
|
+
hasAudio: /Stream #\d+:\d+[^\n]*?: Audio:/.test(stderr),
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const dimensions = stderr.match(
|
|
207
|
+
/Stream #\d+:\d+[^\n]*?: Video:[^\n]*?[,\s](\d{2,5})x(\d{2,5})/
|
|
208
|
+
);
|
|
209
|
+
if (dimensions) {
|
|
210
|
+
info.width = Number(dimensions[1]) || 0;
|
|
211
|
+
info.height = Number(dimensions[2]) || 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const duration = stderr.match(/Duration:\s*(\d+):(\d{2}):(\d{2}(?:\.\d+)?)/);
|
|
215
|
+
if (duration) {
|
|
216
|
+
const seconds =
|
|
217
|
+
Number(duration[1]) * 3600 +
|
|
218
|
+
Number(duration[2]) * 60 +
|
|
219
|
+
Number(duration[3]);
|
|
220
|
+
// Round, but never down to 0 — a sub-second clip still has a duration.
|
|
221
|
+
if (Number.isFinite(seconds) && seconds > 0) {
|
|
222
|
+
info.seconds = Math.max(1, Math.round(seconds));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return info;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Build a JPEG preview of a local image/video file.
|
|
231
|
+
*
|
|
232
|
+
* @param {string} filePath
|
|
233
|
+
* @param {"image"|"video"} kind
|
|
234
|
+
* @returns {Promise<{thumbnail: Buffer, width: number, height: number, seconds: number}|null>}
|
|
235
|
+
*/
|
|
236
|
+
async function generateThumbnail(filePath, kind) {
|
|
237
|
+
if (!ffmpegAvailable) return null;
|
|
238
|
+
|
|
239
|
+
let probe = null;
|
|
240
|
+
let fallback = null; // smallest buffer we managed to produce
|
|
241
|
+
|
|
242
|
+
for (const [width, quality] of THUMB_STEPS) {
|
|
243
|
+
const target = tmpPath("jpg");
|
|
244
|
+
const encodeArgs = [
|
|
245
|
+
"-i",
|
|
246
|
+
filePath,
|
|
247
|
+
"-frames:v",
|
|
248
|
+
"1",
|
|
249
|
+
"-vf",
|
|
250
|
+
`scale='min(${width},iw)':-2`,
|
|
251
|
+
"-q:v",
|
|
252
|
+
String(quality),
|
|
253
|
+
"-f",
|
|
254
|
+
"image2",
|
|
255
|
+
target,
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
// For video, seek a second in — frame 0 of a lot of clips is a black fade.
|
|
259
|
+
let result = await runFfmpeg(
|
|
260
|
+
kind === "video" ? ["-y", "-ss", "1", ...encodeArgs] : ["-y", ...encodeArgs]
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
if (kind === "video" && (!result.ok || !sizeOf(target))) {
|
|
264
|
+
// Clip shorter than the seek point — take the very first frame instead.
|
|
265
|
+
result = await runFfmpeg(["-y", ...encodeArgs]);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!ffmpegAvailable) {
|
|
269
|
+
safeUnlink(target);
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!probe) probe = parseProbe(result.stderr);
|
|
274
|
+
|
|
275
|
+
const bytes = sizeOf(target) ? fs.readFileSync(target) : null;
|
|
276
|
+
safeUnlink(target);
|
|
277
|
+
|
|
278
|
+
if (!bytes?.length) continue;
|
|
279
|
+
if (bytes.length <= MAX_THUMB_BYTES) {
|
|
280
|
+
return { thumbnail: bytes, ...probe };
|
|
281
|
+
}
|
|
282
|
+
fallback = bytes; // still too heavy — try the next, smaller step
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (fallback) return { thumbnail: fallback, ...(probe || {}) };
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// pure-JS image headers (no ffmpeg needed)
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
/** Dimensions straight out of a JPEG's SOF marker. */
|
|
294
|
+
function jpegMeta(buffer) {
|
|
295
|
+
if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return null;
|
|
296
|
+
|
|
297
|
+
let offset = 2;
|
|
298
|
+
while (offset + 9 < buffer.length) {
|
|
299
|
+
if (buffer[offset] !== 0xff) {
|
|
300
|
+
offset++;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
const marker = buffer[offset + 1];
|
|
304
|
+
// standalone markers carry no length
|
|
305
|
+
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
306
|
+
offset += 2;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (marker === 0xd9 || marker === 0xda) break; // end of header / start of scan
|
|
310
|
+
const length = buffer.readUInt16BE(offset + 2);
|
|
311
|
+
// SOF0..SOF15, minus the DHT/JPG/DAC markers that share the range
|
|
312
|
+
const isFrameHeader =
|
|
313
|
+
marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker);
|
|
314
|
+
if (isFrameHeader) {
|
|
315
|
+
return {
|
|
316
|
+
format: "jpeg",
|
|
317
|
+
height: buffer.readUInt16BE(offset + 5),
|
|
318
|
+
width: buffer.readUInt16BE(offset + 7),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
if (length < 2) break;
|
|
322
|
+
offset += 2 + length;
|
|
323
|
+
}
|
|
324
|
+
return { format: "jpeg", width: 0, height: 0 };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Format + dimensions from the file header alone. Covers the formats WhatsApp
|
|
329
|
+
* actually carries; anything else just reports nothing.
|
|
330
|
+
*/
|
|
331
|
+
function readImageMeta(buffer) {
|
|
332
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 16) return null;
|
|
333
|
+
|
|
334
|
+
if (buffer[0] === 0xff && buffer[1] === 0xd8) return jpegMeta(buffer);
|
|
335
|
+
|
|
336
|
+
// PNG: 8-byte signature, then IHDR (length+type) then w/h
|
|
337
|
+
if (buffer.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex"))) {
|
|
338
|
+
return {
|
|
339
|
+
format: "png",
|
|
340
|
+
width: buffer.readUInt32BE(16),
|
|
341
|
+
height: buffer.readUInt32BE(20),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (buffer.subarray(0, 3).toString("latin1") === "GIF") {
|
|
346
|
+
return {
|
|
347
|
+
format: "gif",
|
|
348
|
+
width: buffer.readUInt16LE(6),
|
|
349
|
+
height: buffer.readUInt16LE(8),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (
|
|
354
|
+
buffer.subarray(0, 4).toString("latin1") === "RIFF" &&
|
|
355
|
+
buffer.subarray(8, 12).toString("latin1") === "WEBP"
|
|
356
|
+
) {
|
|
357
|
+
const chunk = buffer.subarray(12, 16).toString("latin1");
|
|
358
|
+
if (chunk === "VP8X" && buffer.length >= 30) {
|
|
359
|
+
return {
|
|
360
|
+
format: "webp",
|
|
361
|
+
width: (buffer.readUIntLE(24, 3) & 0xffffff) + 1,
|
|
362
|
+
height: (buffer.readUIntLE(27, 3) & 0xffffff) + 1,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
if (chunk === "VP8 " && buffer.length >= 30) {
|
|
366
|
+
return {
|
|
367
|
+
format: "webp",
|
|
368
|
+
width: buffer.readUInt16LE(26) & 0x3fff,
|
|
369
|
+
height: buffer.readUInt16LE(28) & 0x3fff,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
if (chunk === "VP8L" && buffer.length >= 25) {
|
|
373
|
+
const bits = buffer.readUInt32LE(21);
|
|
374
|
+
return {
|
|
375
|
+
format: "webp",
|
|
376
|
+
width: (bits & 0x3fff) + 1,
|
|
377
|
+
height: ((bits >> 14) & 0x3fff) + 1,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
return { format: "webp", width: 0, height: 0 };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Read just enough of a file to identify it (JPEG SOF can sit past big EXIF). */
|
|
387
|
+
function readHead(filePath, bytes = 512 * 1024) {
|
|
388
|
+
let fd;
|
|
389
|
+
try {
|
|
390
|
+
fd = fs.openSync(filePath, "r");
|
|
391
|
+
const buffer = Buffer.alloc(Math.min(bytes, sizeOf(filePath)));
|
|
392
|
+
fs.readSync(fd, buffer, 0, buffer.length, 0);
|
|
393
|
+
return buffer;
|
|
394
|
+
} catch {
|
|
395
|
+
return null;
|
|
396
|
+
} finally {
|
|
397
|
+
if (fd !== undefined) {
|
|
398
|
+
try {
|
|
399
|
+
fs.closeSync(fd);
|
|
400
|
+
} catch {
|
|
401
|
+
// ignore
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// content enrichment
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
/** Give ffmpeg a filename it can demux from: guess the extension. */
|
|
412
|
+
function extensionFor(buffer, kind) {
|
|
413
|
+
const meta = readImageMeta(buffer);
|
|
414
|
+
if (meta?.format) return meta.format === "jpeg" ? "jpg" : meta.format;
|
|
415
|
+
return kind === "video" ? "mp4" : "bin";
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Fetch a remote asset to a temp file so we can build a preview from it. */
|
|
419
|
+
async function downloadToTemp(url, kind) {
|
|
420
|
+
const axios = getAxios();
|
|
421
|
+
if (!axios) return null;
|
|
422
|
+
|
|
423
|
+
const response = await axios.get(url, {
|
|
424
|
+
responseType: "arraybuffer",
|
|
425
|
+
timeout: remoteTimeoutMs(),
|
|
426
|
+
maxRedirects: 4,
|
|
427
|
+
maxContentLength: remoteMaxBytes(),
|
|
428
|
+
headers: { Accept: "*/*" },
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const buffer = Buffer.from(response.data);
|
|
432
|
+
if (!buffer.length) return null;
|
|
433
|
+
|
|
434
|
+
const file = tmpPath(extensionFor(buffer, kind));
|
|
435
|
+
await fs.promises.writeFile(file, buffer);
|
|
436
|
+
return { file, temp: true };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Turn whatever Baileys accepts as media into a local file path.
|
|
441
|
+
* Streams are skipped on purpose: consuming one here would leave nothing for
|
|
442
|
+
* the actual upload.
|
|
443
|
+
*/
|
|
444
|
+
async function resolveSource(media, kind) {
|
|
445
|
+
if (Buffer.isBuffer(media)) {
|
|
446
|
+
if (!media.length) return null;
|
|
447
|
+
const file = tmpPath(extensionFor(media, kind));
|
|
448
|
+
await fs.promises.writeFile(file, media);
|
|
449
|
+
return { file, temp: true };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const url =
|
|
453
|
+
typeof media === "string"
|
|
454
|
+
? media
|
|
455
|
+
: media && typeof media === "object" && typeof media.url === "string"
|
|
456
|
+
? media.url
|
|
457
|
+
: null;
|
|
458
|
+
|
|
459
|
+
if (!url) return null;
|
|
460
|
+
|
|
461
|
+
if (/^https?:\/\//i.test(url)) {
|
|
462
|
+
if (!remoteThumbsEnabled()) return null;
|
|
463
|
+
try {
|
|
464
|
+
return await downloadToTemp(url, kind);
|
|
465
|
+
} catch (err) {
|
|
466
|
+
logger.debug(
|
|
467
|
+
{ err: err?.message, url },
|
|
468
|
+
"[thumbnail] remote source could not be fetched for a preview"
|
|
469
|
+
);
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return sizeOf(url) ? { file: url, temp: false } : null;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* What in this content could carry a preview?
|
|
479
|
+
* image / video -> the obvious ones
|
|
480
|
+
* document + image|video mimetype -> WhatsApp renders a preview for those
|
|
481
|
+
* too, and a document was the one send
|
|
482
|
+
* path that never got one
|
|
483
|
+
* Everything else (text, audio, stickers, edits, reactions, deletes) is passed
|
|
484
|
+
* straight through.
|
|
485
|
+
*
|
|
486
|
+
* @returns {{field: string, kind: "image"|"video"}|null}
|
|
487
|
+
*/
|
|
488
|
+
function previewTarget(content) {
|
|
489
|
+
if (content.image) return { field: "image", kind: "image" };
|
|
490
|
+
if (content.video) return { field: "video", kind: "video" };
|
|
491
|
+
|
|
492
|
+
if (content.document) {
|
|
493
|
+
const mime = String(content.mimetype || "");
|
|
494
|
+
if (mime.startsWith("image/")) return { field: "document", kind: "image" };
|
|
495
|
+
if (mime.startsWith("video/")) return { field: "document", kind: "video" };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Return `content` with `jpegThumbnail` (and width/height/seconds when we can
|
|
503
|
+
* work them out) filled in. Never throws: a missing preview is not a reason to
|
|
504
|
+
* drop a message.
|
|
505
|
+
*/
|
|
506
|
+
async function withMediaThumbnail(content) {
|
|
507
|
+
if (!content || typeof content !== "object") return content;
|
|
508
|
+
|
|
509
|
+
const target = previewTarget(content);
|
|
510
|
+
if (!target) return content;
|
|
511
|
+
if (content.jpegThumbnail) return content; // caller supplied its own
|
|
512
|
+
|
|
513
|
+
const { field, kind } = target;
|
|
514
|
+
|
|
515
|
+
let source = null;
|
|
516
|
+
try {
|
|
517
|
+
source = await resolveSource(content[field], kind);
|
|
518
|
+
if (!source) return content;
|
|
519
|
+
|
|
520
|
+
const info = await generateThumbnail(source.file, kind);
|
|
521
|
+
|
|
522
|
+
// ffmpeg is missing or choked on this file — read what we can from the
|
|
523
|
+
// header, and let a small JPEG stand in as its own preview.
|
|
524
|
+
let headerMeta = null;
|
|
525
|
+
const readHeaderMeta = () => {
|
|
526
|
+
if (headerMeta !== null) return headerMeta;
|
|
527
|
+
const head = readHead(source.file);
|
|
528
|
+
headerMeta = (head && readImageMeta(head)) || false;
|
|
529
|
+
return headerMeta;
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
let thumbnail = info?.thumbnail || null;
|
|
533
|
+
if (!thumbnail && kind === "image") {
|
|
534
|
+
const meta = readHeaderMeta();
|
|
535
|
+
if (meta && meta.format === "jpeg" && sizeOf(source.file) <= RAW_JPEG_FALLBACK_BYTES) {
|
|
536
|
+
thumbnail = fs.readFileSync(source.file);
|
|
537
|
+
logger.debug(
|
|
538
|
+
{ bytes: thumbnail.length },
|
|
539
|
+
"[thumbnail] reusing the image itself as its preview"
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Dimensions matter even when the preview itself failed: WhatsApp sizes the
|
|
545
|
+
// bubble from them, so a wrong/zero size is a visibly broken message.
|
|
546
|
+
let width = info?.width || 0;
|
|
547
|
+
let height = info?.height || 0;
|
|
548
|
+
if ((!width || !height) && kind === "image") {
|
|
549
|
+
const meta = readHeaderMeta();
|
|
550
|
+
if (meta) {
|
|
551
|
+
width = width || meta.width || 0;
|
|
552
|
+
height = height || meta.height || 0;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const patch = {};
|
|
557
|
+
if (thumbnail) patch.jpegThumbnail = thumbnail;
|
|
558
|
+
if (width && height && !content.width && !content.height) {
|
|
559
|
+
patch.width = width;
|
|
560
|
+
patch.height = height;
|
|
561
|
+
}
|
|
562
|
+
if (kind === "video" && info?.seconds && !content.seconds) {
|
|
563
|
+
patch.seconds = info.seconds;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (!Object.keys(patch).length) {
|
|
567
|
+
logger.debug({ kind, field }, "[thumbnail] no preview could be built");
|
|
568
|
+
return content;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return { ...content, ...patch };
|
|
572
|
+
} catch (err) {
|
|
573
|
+
logger.debug(
|
|
574
|
+
{ err: err?.message, kind, field },
|
|
575
|
+
"[thumbnail] could not build a preview — sending without one"
|
|
576
|
+
);
|
|
577
|
+
return content;
|
|
578
|
+
} finally {
|
|
579
|
+
if (source?.temp) safeUnlink(source.file);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
module.exports = {
|
|
584
|
+
withMediaThumbnail,
|
|
585
|
+
previewTarget,
|
|
586
|
+
generateThumbnail,
|
|
587
|
+
readImageMeta,
|
|
588
|
+
checkFfmpeg,
|
|
589
|
+
isFfmpegAvailable: () => ffmpegAvailable,
|
|
590
|
+
ffmpegPath: () => ffmpegBin(),
|
|
591
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Typing simulation utility
|
|
2
|
+
import { createRequire } from 'module';
|
|
3
|
+
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
const logger = require('./logger.cjs');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Simulates typing with a random delay between 300-500ms
|
|
9
|
+
* @param {Object} sock - WhatsApp socket instance
|
|
10
|
+
* @param {string} jid - Chat JID to send typing indicator to
|
|
11
|
+
* @param {number} minDelay - Minimum delay in ms (default: 300)
|
|
12
|
+
* @param {number} maxDelay - Maximum delay in ms (default: 500)
|
|
13
|
+
* @returns {Promise<void>}
|
|
14
|
+
*/
|
|
15
|
+
export async function simulateTyping(sock, jid, minDelay = 300, maxDelay = 500) {
|
|
16
|
+
try {
|
|
17
|
+
// Send typing indicator
|
|
18
|
+
await sock.sendPresenceUpdate('composing', jid);
|
|
19
|
+
|
|
20
|
+
// Random delay between min and max
|
|
21
|
+
const delay = Math.floor(Math.random() * (maxDelay - minDelay + 1)) + minDelay;
|
|
22
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
23
|
+
|
|
24
|
+
// Stop typing indicator
|
|
25
|
+
await sock.sendPresenceUpdate('paused', jid);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
logger.error({ err: error }, '[Typing] Error simulating typing');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Send a message with typing simulation
|
|
33
|
+
* @param {Object} sock - WhatsApp socket instance
|
|
34
|
+
* @param {string} jid - Chat JID
|
|
35
|
+
* @param {Object} message - Message content
|
|
36
|
+
* @param {boolean} withTyping - Whether to simulate typing (default: true)
|
|
37
|
+
* @returns {Promise<Object>} - Sent message info
|
|
38
|
+
*/
|
|
39
|
+
export async function sendMessageWithTyping(sock, jid, message, withTyping = true) {
|
|
40
|
+
try {
|
|
41
|
+
if (withTyping) {
|
|
42
|
+
await simulateTyping(sock, jid);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sent = await sock.sendMessage(jid, message);
|
|
46
|
+
return sent;
|
|
47
|
+
} catch (error) {
|
|
48
|
+
logger.error({ err: error }, '[Typing] Error sending message with typing');
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Calculate appropriate typing delay based on message length
|
|
55
|
+
* Simulates realistic typing speed
|
|
56
|
+
* @param {string} text - Message text
|
|
57
|
+
* @param {number} charsPerSecond - Average typing speed (default: 40)
|
|
58
|
+
* @returns {number} - Delay in ms (capped between 300-3000ms)
|
|
59
|
+
*/
|
|
60
|
+
export function calculateTypingDelay(text, charsPerSecond = 40) {
|
|
61
|
+
if (!text) return 300;
|
|
62
|
+
|
|
63
|
+
const baseDelay = (text.length / charsPerSecond) * 1000;
|
|
64
|
+
// Cap between 300ms and 3000ms
|
|
65
|
+
return Math.min(Math.max(baseDelay, 300), 3000);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Send message with realistic typing delay based on text length
|
|
70
|
+
* @param {Object} sock - WhatsApp socket instance
|
|
71
|
+
* @param {string} jid - Chat JID
|
|
72
|
+
* @param {Object} message - Message content
|
|
73
|
+
* @returns {Promise<Object>} - Sent message info
|
|
74
|
+
*/
|
|
75
|
+
export async function sendMessageWithRealisticTyping(sock, jid, message) {
|
|
76
|
+
try {
|
|
77
|
+
const text = message.text || message.caption || '';
|
|
78
|
+
const delay = calculateTypingDelay(text);
|
|
79
|
+
|
|
80
|
+
// Send typing indicator
|
|
81
|
+
await sock.sendPresenceUpdate('composing', jid);
|
|
82
|
+
|
|
83
|
+
// Wait for calculated delay
|
|
84
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
85
|
+
|
|
86
|
+
// Stop typing and send message
|
|
87
|
+
await sock.sendPresenceUpdate('paused', jid);
|
|
88
|
+
const sent = await sock.sendMessage(jid, message);
|
|
89
|
+
|
|
90
|
+
return sent;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
logger.error({ err: error }, '[Typing] Error sending message with realistic typing');
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default {
|
|
98
|
+
simulateTyping,
|
|
99
|
+
sendMessageWithTyping,
|
|
100
|
+
calculateTypingDelay,
|
|
101
|
+
sendMessageWithRealisticTyping,
|
|
102
|
+
};
|