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,73 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
import { delay } from '@whiskeysockets/baileys';
|
|
3
|
+
import { getGroupSettings } from '../utils/storage.esm.js';
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const logger = require('../utils/logger.cjs');
|
|
7
|
+
|
|
8
|
+
// Handle group participant updates (join/leave)
|
|
9
|
+
export async function handleGroupParticipantsUpdate(sock, update) {
|
|
10
|
+
const { id, participants, action } = update;
|
|
11
|
+
logger.info({ update }, 'Received group participants update');
|
|
12
|
+
|
|
13
|
+
if (action !== 'add') {
|
|
14
|
+
logger.info(`[Group] ${action} action for ${id}`);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const settings = getGroupSettings(id);
|
|
20
|
+
const welcomeConfig = settings?.welcome_system;
|
|
21
|
+
|
|
22
|
+
if (!welcomeConfig || !welcomeConfig.enabled || welcomeConfig.messages.length === 0) {
|
|
23
|
+
logger.warn(`[Group] Welcome system disabled or no messages for ${id}`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Send welcome message to each new participant
|
|
28
|
+
for (const participant of participants) {
|
|
29
|
+
const randomIndex = Math.floor(Math.random() * welcomeConfig.messages.length);
|
|
30
|
+
const welcomeMessage = welcomeConfig.messages[randomIndex];
|
|
31
|
+
|
|
32
|
+
const finalMessage = welcomeMessage.replace(/\${user}/g, `@${participant.split('@')[0]}`);
|
|
33
|
+
|
|
34
|
+
await delay(1000);
|
|
35
|
+
await sock.sendMessage(id, {
|
|
36
|
+
text: finalMessage,
|
|
37
|
+
mentions: [participant],
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
} catch (error) {
|
|
41
|
+
logger.error({ err: error, update }, 'Error in group-participants.update event');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Handle group join requests
|
|
46
|
+
export async function handleGroupJoinRequests(sock, events) {
|
|
47
|
+
logger.info({ events }, 'Received group join request update');
|
|
48
|
+
|
|
49
|
+
for (const request of events) {
|
|
50
|
+
if (request.type !== 'request') continue;
|
|
51
|
+
|
|
52
|
+
const groupId = request.jid;
|
|
53
|
+
const participantId = request.from;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const settings = getGroupSettings(groupId);
|
|
57
|
+
|
|
58
|
+
if (settings?.join_requests?.auto_approve_enabled) {
|
|
59
|
+
logger.info(`[Auto-Approve] Approving ${participantId} for group ${groupId}`);
|
|
60
|
+
|
|
61
|
+
await sock.groupRequestUpdate(groupId, participantId, 'approve');
|
|
62
|
+
|
|
63
|
+
await delay(500);
|
|
64
|
+
await sock.sendMessage(groupId, {
|
|
65
|
+
text: `✅ تم قبول طلب انضمام @${participantId.split('@')[0]} تلقائيًا.`,
|
|
66
|
+
mentions: [participantId],
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
logger.error({ err: error, request }, 'Error in auto-approve event');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { createRequire } from "module";
|
|
2
|
+
import { handleCommand } from "./command.handler.js";
|
|
3
|
+
import { handleAntiSpam } from "../middleware/antispam.middleware.js";
|
|
4
|
+
import { checkBlacklist } from "../middleware/blacklist.middleware.js";
|
|
5
|
+
import { trackForwardedMessage } from "../middleware/forward-tracking.middleware.js";
|
|
6
|
+
import { updateUserLastSeen, saveUserMetadata } from "../utils/storage.esm.js";
|
|
7
|
+
import { rememberMessage } from "../utils/recentMessageCache.esm.js";
|
|
8
|
+
import normalizeJid from "../utils/normalizeJid.esm.js";
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const logger = require("../utils/logger.cjs");
|
|
12
|
+
|
|
13
|
+
// Envelopes that carry no user-visible content: message revokes and edits
|
|
14
|
+
// (protocolMessage), reactions, poll votes, the undecrypted edit envelope, and
|
|
15
|
+
// the sibling keys WhatsApp ships alongside a real message.
|
|
16
|
+
//
|
|
17
|
+
// These used to be swallowed by the anti-delete handler, which returned early
|
|
18
|
+
// on them. With that handler gone they have to be filtered here instead —
|
|
19
|
+
// otherwise they reach group moderation, where anti-spam counts each one as a
|
|
20
|
+
// message from the sender and deleting a few of your own messages in a row
|
|
21
|
+
// gets you warned or kicked.
|
|
22
|
+
const NON_CONTENT_KEYS = new Set([
|
|
23
|
+
"protocolMessage",
|
|
24
|
+
"reactionMessage",
|
|
25
|
+
"pollUpdateMessage",
|
|
26
|
+
"secretEncryptedMessage",
|
|
27
|
+
"senderKeyDistributionMessage",
|
|
28
|
+
"messageContextInfo",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
// True when the envelope holds something a person actually sent. Note the
|
|
32
|
+
// `.some()`: WhatsApp routinely ships senderKeyDistributionMessage next to a
|
|
33
|
+
// real `conversation`, so we look for any content key rather than testing the
|
|
34
|
+
// first one.
|
|
35
|
+
function hasUserContent(message) {
|
|
36
|
+
if (!message) return false;
|
|
37
|
+
return Object.keys(message).some((key) => !NON_CONTENT_KEYS.has(key));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Handle incoming messages
|
|
41
|
+
export async function handleIncomingMessage(sock, m) {
|
|
42
|
+
if (m.type !== "notify" || !m.messages[0]) return;
|
|
43
|
+
|
|
44
|
+
const msg = m.messages[0];
|
|
45
|
+
|
|
46
|
+
// "Message absent from node" is a CIPHERTEXT placeholder: the message node
|
|
47
|
+
// arrived but its content couldn't be decrypted (Baileys requests a resend).
|
|
48
|
+
if (msg?.messageStubParameters?.[0] === "Message absent from node") {
|
|
49
|
+
logger.debug("[Message] Skipping undecryptable/absent message stub");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Baileys' getMessage hook needs to hand back messages WE sent when a peer
|
|
54
|
+
// asks for a retry (see utils/recentMessageCache.esm.js). Only our own
|
|
55
|
+
// outgoing messages are kept, in memory, and only for that purpose.
|
|
56
|
+
if (msg.key?.fromMe && msg.key.id && msg.message) {
|
|
57
|
+
rememberMessage(msg.key.id, msg);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!hasUserContent(msg.message)) return;
|
|
61
|
+
|
|
62
|
+
// Track forwarded messages
|
|
63
|
+
await trackForwardedMessage(msg);
|
|
64
|
+
|
|
65
|
+
// Skip onward processing for status broadcasts and newsletters.
|
|
66
|
+
if (
|
|
67
|
+
msg.key.remoteJid === "status@broadcast" ||
|
|
68
|
+
msg.key.remoteJid?.endsWith("@broadcast") ||
|
|
69
|
+
msg.key.remoteJid?.endsWith("@newsletter")
|
|
70
|
+
) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Parse message context. Crucially we ALSO consider media captions —
|
|
75
|
+
// otherwise sending an image with caption "!gemini حلل ده" is invisible
|
|
76
|
+
// to the command handler and the user sees nothing happen.
|
|
77
|
+
const body =
|
|
78
|
+
msg.message?.conversation ||
|
|
79
|
+
msg.message?.extendedTextMessage?.text ||
|
|
80
|
+
msg.message?.imageMessage?.caption ||
|
|
81
|
+
msg.message?.videoMessage?.caption ||
|
|
82
|
+
msg.message?.documentMessage?.caption ||
|
|
83
|
+
"";
|
|
84
|
+
const isGroup = msg.key.remoteJid.endsWith("@g.us");
|
|
85
|
+
|
|
86
|
+
// Update user metadata (last seen)
|
|
87
|
+
const senderId = normalizeJid(msg.key.participant || msg.key.remoteJid);
|
|
88
|
+
if (
|
|
89
|
+
senderId &&
|
|
90
|
+
!senderId.includes("@broadcast") &&
|
|
91
|
+
!senderId.includes("@newsletter")
|
|
92
|
+
) {
|
|
93
|
+
try {
|
|
94
|
+
saveUserMetadata({
|
|
95
|
+
jid: senderId,
|
|
96
|
+
lid: null,
|
|
97
|
+
phone: senderId.split("@")[0],
|
|
98
|
+
displayName: msg.pushName || null,
|
|
99
|
+
});
|
|
100
|
+
updateUserLastSeen(senderId);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
logger.error({ err }, "Failed to update user metadata");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (isGroup) {
|
|
107
|
+
const isBlacklisted = await checkBlacklist(sock, msg);
|
|
108
|
+
if (isBlacklisted) return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Try to handle as command
|
|
112
|
+
try {
|
|
113
|
+
const wasCommand = await handleCommand(sock, msg, body);
|
|
114
|
+
if (wasCommand) {
|
|
115
|
+
logger.info(`[Message] Handled as command: "${body}"`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
logger.error({ err: error }, "Error handling command");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Handle regular group messages (moderation)
|
|
123
|
+
if (isGroup) {
|
|
124
|
+
await handleGroupModeration(sock, msg);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Handle group moderation
|
|
129
|
+
async function handleGroupModeration(sock, msg) {
|
|
130
|
+
try {
|
|
131
|
+
const { handleAntiLink } = require("../commands/group/antilink.cjs");
|
|
132
|
+
const { handleMediaControl } = require("../commands/group/media.cjs");
|
|
133
|
+
|
|
134
|
+
const linkActionTaken = await handleAntiLink(sock, msg, {}, normalizeJid);
|
|
135
|
+
if (linkActionTaken) return;
|
|
136
|
+
|
|
137
|
+
const mediaActionTaken = await handleMediaControl(
|
|
138
|
+
sock,
|
|
139
|
+
msg,
|
|
140
|
+
{},
|
|
141
|
+
normalizeJid
|
|
142
|
+
);
|
|
143
|
+
if (mediaActionTaken) return;
|
|
144
|
+
|
|
145
|
+
await handleAntiSpam(sock, msg);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
logger.error({ err: error }, "Error in group moderation");
|
|
148
|
+
}
|
|
149
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Starting Levix.
|
|
2
|
+
//
|
|
3
|
+
// Two shapes, one core:
|
|
4
|
+
//
|
|
5
|
+
// levix bootstrapCore() + bootstrapPanel(), then maybe a browser
|
|
6
|
+
// levix headless bootstrapCore() only — nothing binds a port
|
|
7
|
+
//
|
|
8
|
+
// The difference is which bootstrap runs, not a flag threaded through the
|
|
9
|
+
// codebase. See src/bootstrap/.
|
|
10
|
+
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import { createRequire } from "module";
|
|
13
|
+
import { bootstrapCore } from "./bootstrap/core.js";
|
|
14
|
+
import { flushStore } from "./db/store.esm.js";
|
|
15
|
+
import brand from "./config/brand.esm.js";
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
const logger = require("./utils/logger.cjs");
|
|
19
|
+
const secrets = require("./config/secrets.cjs");
|
|
20
|
+
const settings = require("./config/settings.cjs");
|
|
21
|
+
const { DATA_DIR } = require("./config/paths.cjs");
|
|
22
|
+
const { release: releaseLock } = require("./config/lock.cjs");
|
|
23
|
+
const { close: closeDatabase } = require("./db/db.cjs");
|
|
24
|
+
const { attach } = require("./bootstrap/events.cjs");
|
|
25
|
+
const { openBrowser } = require("./utils/openBrowser.cjs");
|
|
26
|
+
|
|
27
|
+
const line = (text = "") => console.log(text);
|
|
28
|
+
|
|
29
|
+
/** `/home/abdo/.levix` reads better as `~/.levix`. */
|
|
30
|
+
function short(path) {
|
|
31
|
+
const home = os.homedir();
|
|
32
|
+
return home && path.startsWith(home) ? path.replace(home, "~") : path;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Start Levix.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} [options]
|
|
39
|
+
* @param {boolean} [options.headless] - run the bot with no web panel at all
|
|
40
|
+
* @param {boolean} [options.open] - open a browser when the environment suits it
|
|
41
|
+
*/
|
|
42
|
+
export async function start({ headless = false, open = true } = {}) {
|
|
43
|
+
logger.info(`Starting ${brand.name} — ${brand.tagline}`);
|
|
44
|
+
logger.info(`[Data] ${DATA_DIR}`);
|
|
45
|
+
|
|
46
|
+
installShutdownHandlers();
|
|
47
|
+
|
|
48
|
+
return headless ? startHeadless() : startWithPanel({ open });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// headless
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
async function startHeadless() {
|
|
56
|
+
line();
|
|
57
|
+
line(` ${brand.name} — headless`);
|
|
58
|
+
|
|
59
|
+
// Attached before the bot starts, because the QR can arrive during the very
|
|
60
|
+
// first connection attempt.
|
|
61
|
+
let announcedPairing = false;
|
|
62
|
+
attach((event, payload) => {
|
|
63
|
+
if (event === "qr" && !announcedPairing) {
|
|
64
|
+
announcedPairing = true;
|
|
65
|
+
line();
|
|
66
|
+
line(" No WhatsApp session found.");
|
|
67
|
+
line();
|
|
68
|
+
line(" Scan this QR from:");
|
|
69
|
+
line(" WhatsApp -> Linked devices -> Link a device");
|
|
70
|
+
line();
|
|
71
|
+
}
|
|
72
|
+
if (event === "status_update" && payload?.status === "Connected") {
|
|
73
|
+
line();
|
|
74
|
+
line(" ✓ WhatsApp connected");
|
|
75
|
+
}
|
|
76
|
+
if (event === "status_update" && payload?.status === "Disconnected") {
|
|
77
|
+
line(" … WhatsApp disconnected, reconnecting");
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const core = await bootstrapCore();
|
|
82
|
+
|
|
83
|
+
if (announcedPairing) line(" Waiting for connection...");
|
|
84
|
+
|
|
85
|
+
line();
|
|
86
|
+
line(" ✓ Levix is ready");
|
|
87
|
+
line();
|
|
88
|
+
line(` ${core.commandCount} commands loaded`);
|
|
89
|
+
line(` Data: ${short(DATA_DIR)}`);
|
|
90
|
+
line();
|
|
91
|
+
line(" No web panel in this mode. Press Ctrl+C to stop.");
|
|
92
|
+
line();
|
|
93
|
+
|
|
94
|
+
return core;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// with the panel
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
async function startWithPanel({ open }) {
|
|
102
|
+
const { bootstrapPanel, panelUrl } = await import("./bootstrap/panel.js");
|
|
103
|
+
|
|
104
|
+
let setBotInstance = null;
|
|
105
|
+
let setBotControls = null;
|
|
106
|
+
|
|
107
|
+
// The panel needs the live socket, and a reconnect replaces it. Passing the
|
|
108
|
+
// callback into the core keeps that true without the core knowing what a
|
|
109
|
+
// panel is.
|
|
110
|
+
const core = await bootstrapCore({
|
|
111
|
+
onSocket(sock, controls) {
|
|
112
|
+
if (setBotInstance) setBotInstance(sock);
|
|
113
|
+
if (setBotControls) setBotControls({ clearAll: controls.clearAll });
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
let panel;
|
|
118
|
+
try {
|
|
119
|
+
panel = await bootstrapPanel({ core });
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error.code === "EADDRINUSE") {
|
|
122
|
+
console.error(`\n ${error.message}\n`);
|
|
123
|
+
logger.error(error.message);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
({ setBotInstance, setBotControls } = panel);
|
|
130
|
+
setBotInstance(core.sock);
|
|
131
|
+
|
|
132
|
+
const firstRun = !secrets.hasDashboardPassword();
|
|
133
|
+
const url = panelUrl({ port: panel.port, firstRun });
|
|
134
|
+
|
|
135
|
+
logger.info(`✅ Control panel: ${url}`);
|
|
136
|
+
|
|
137
|
+
line();
|
|
138
|
+
line(` ${brand.name} is running`);
|
|
139
|
+
line();
|
|
140
|
+
line(` Panel: ${url}`);
|
|
141
|
+
line(` Data: ${short(DATA_DIR)}`);
|
|
142
|
+
|
|
143
|
+
if (firstRun && !settings.get("public_domain")) {
|
|
144
|
+
line();
|
|
145
|
+
line(" First run — that link asks you to pick a password.");
|
|
146
|
+
line(` Opening it from another machine also needs this code: ${secrets.getSetupCode()}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (open) {
|
|
150
|
+
const { opened, reason } = openBrowser(url);
|
|
151
|
+
line();
|
|
152
|
+
line(
|
|
153
|
+
opened
|
|
154
|
+
? ` Opening ${brand.name} in your browser...`
|
|
155
|
+
: ` Open that link yourself (${reason}).`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
line();
|
|
160
|
+
line(" Press Ctrl+C to stop.");
|
|
161
|
+
line();
|
|
162
|
+
|
|
163
|
+
return { ...core, panel, url };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// living and dying
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
function installShutdownHandlers() {
|
|
171
|
+
// After an uncaughtException the process state is unknown (a half-held lock,
|
|
172
|
+
// a half-written auth row). Log and exit rather than keep working on top of
|
|
173
|
+
// it. Run the bot under a supervisor (systemd / pm2 / docker restart) so it
|
|
174
|
+
// comes back on its own — without one, this exit means the bot is down.
|
|
175
|
+
const fatal = (error, label) => {
|
|
176
|
+
logger.error(error, `${label} exiting — restart is up to the supervisor`);
|
|
177
|
+
console.error(label, error);
|
|
178
|
+
// Half a second so pino can drain its worker before we die.
|
|
179
|
+
setTimeout(() => process.exit(1), 500).unref();
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
process.on("uncaughtException", (error) => fatal(error, "Uncaught Exception:"));
|
|
183
|
+
process.on("unhandledRejection", (error) => fatal(error, "Unhandled Rejection:"));
|
|
184
|
+
|
|
185
|
+
let shuttingDown = false;
|
|
186
|
+
const shutdown = async (signal) => {
|
|
187
|
+
if (shuttingDown) return;
|
|
188
|
+
shuttingDown = true;
|
|
189
|
+
logger.info(`[shutdown] ${signal} received, closing down...`);
|
|
190
|
+
|
|
191
|
+
const finish = async () => {
|
|
192
|
+
await Promise.allSettled([flushStore()]);
|
|
193
|
+
closeDatabase();
|
|
194
|
+
releaseLock();
|
|
195
|
+
process.exit(0);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// The panel may not exist at all (headless), and if it does it may not be
|
|
199
|
+
// listening yet. Reaching into the module cache rather than requiring it
|
|
200
|
+
// keeps this from constructing an Express app during shutdown.
|
|
201
|
+
let server = null;
|
|
202
|
+
try {
|
|
203
|
+
const cached = require.cache[require.resolve("../app.cjs")];
|
|
204
|
+
server = cached?.exports?.server ?? null;
|
|
205
|
+
} catch {}
|
|
206
|
+
|
|
207
|
+
if (server?.listening) server.close(finish);
|
|
208
|
+
else await finish();
|
|
209
|
+
|
|
210
|
+
// Don't wait forever on a request that refuses to end.
|
|
211
|
+
setTimeout(() => process.exit(1), 10_000).unref();
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
["SIGTERM", "SIGINT"].forEach((signal) => process.on(signal, () => shutdown(signal)));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export default start;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
import { getGroupSettings } from '../utils/storage.esm.js';
|
|
3
|
+
import normalizeJid from '../utils/normalizeJid.esm.js';
|
|
4
|
+
import {
|
|
5
|
+
isOwnerJid,
|
|
6
|
+
isAdminInGroup,
|
|
7
|
+
} from '../utils/permissions.esm.js';
|
|
8
|
+
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const logger = require('../utils/logger.cjs');
|
|
11
|
+
|
|
12
|
+
const userMessageTimestamps = new Map();
|
|
13
|
+
|
|
14
|
+
// Handle anti-spam detection and action
|
|
15
|
+
export async function handleAntiSpam(sock, msg) {
|
|
16
|
+
const groupId = msg.key.remoteJid;
|
|
17
|
+
if (!groupId.endsWith('@g.us')) return;
|
|
18
|
+
|
|
19
|
+
const senderId = normalizeJid(msg.key.participant);
|
|
20
|
+
const settings = getGroupSettings(groupId);
|
|
21
|
+
const spamConfig = settings?.antispam;
|
|
22
|
+
|
|
23
|
+
if (!spamConfig || !spamConfig.enabled) return;
|
|
24
|
+
|
|
25
|
+
// Owners and admins are immune. The legacy owner mirror it used to import was
|
|
26
|
+
// only ever populated with the bot's own JID, so real operators were
|
|
27
|
+
// counted as spammers — the centralized check fixes that.
|
|
28
|
+
const isOwner = isOwnerJid(senderId);
|
|
29
|
+
const groupMetadata = await sock.groupMetadata(groupId);
|
|
30
|
+
const isSenderAdmin = isAdminInGroup(groupMetadata, senderId);
|
|
31
|
+
|
|
32
|
+
if (isOwner || isSenderAdmin) return;
|
|
33
|
+
|
|
34
|
+
// Track message timestamps
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
if (!userMessageTimestamps.has(senderId)) {
|
|
37
|
+
userMessageTimestamps.set(senderId, []);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const userTimestamps = userMessageTimestamps.get(senderId);
|
|
41
|
+
userTimestamps.push(now);
|
|
42
|
+
|
|
43
|
+
// Filter timestamps within time window
|
|
44
|
+
const timeWindow = (spamConfig.time_window || 10) * 1000;
|
|
45
|
+
const recentTimestamps = userTimestamps.filter((ts) => now - ts < timeWindow);
|
|
46
|
+
userMessageTimestamps.set(senderId, recentTimestamps);
|
|
47
|
+
|
|
48
|
+
// Check if user exceeded message count
|
|
49
|
+
if (recentTimestamps.length > (spamConfig.message_count || 5)) {
|
|
50
|
+
logger.warn({ user: senderId, group: groupId }, 'Spam detected, taking action.');
|
|
51
|
+
|
|
52
|
+
// Take action based on config
|
|
53
|
+
if (spamConfig.action === 'KICK') {
|
|
54
|
+
await sock.sendMessage(groupId, {
|
|
55
|
+
text: `🚫 تم حذف @${senderId.split('@')[0]} بسبب الإزعاج (Spam).`,
|
|
56
|
+
mentions: [senderId],
|
|
57
|
+
});
|
|
58
|
+
await sock.groupParticipantsUpdate(groupId, [senderId], 'remove');
|
|
59
|
+
} else {
|
|
60
|
+
await sock.sendMessage(groupId, {
|
|
61
|
+
text: `⚠️ تحذير لـ @${senderId.split('@')[0]}! الرجاء عدم إرسال رسائل مزعجة.`,
|
|
62
|
+
mentions: [senderId],
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
userMessageTimestamps.delete(senderId);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
import { getGroupSettings } from '../utils/storage.esm.js';
|
|
3
|
+
import normalizeJid from '../utils/normalizeJid.esm.js';
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const logger = require('../utils/logger.cjs');
|
|
7
|
+
|
|
8
|
+
// Check if user is blacklisted in the group
|
|
9
|
+
export async function checkBlacklist(sock, msg) {
|
|
10
|
+
const groupId = msg.key.remoteJid;
|
|
11
|
+
if (!groupId.endsWith('@g.us')) return false;
|
|
12
|
+
|
|
13
|
+
const senderId = normalizeJid(msg.key.participant || msg.key.remoteJid);
|
|
14
|
+
const settings = getGroupSettings(groupId);
|
|
15
|
+
|
|
16
|
+
if (settings?.blacklist?.includes(senderId)) {
|
|
17
|
+
const groupMetadata = await sock.groupMetadata(groupId);
|
|
18
|
+
const isSenderAdmin = groupMetadata.participants.some(
|
|
19
|
+
(p) => p.admin && p.id === senderId
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
if (!isSenderAdmin) {
|
|
23
|
+
logger.info(`Ignoring message from blacklisted user ${senderId}`);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// file: /middleware/forward-tracking.middleware.js
|
|
2
|
+
import { createRequire } from "module";
|
|
3
|
+
import { incrementForwardScore } from "../utils/storage.esm.js";
|
|
4
|
+
import normalizeJid from "../utils/normalizeJid.esm.js";
|
|
5
|
+
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const logger = require("../utils/logger.cjs");
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Track forwarded messages and increment their score
|
|
11
|
+
* @param {object} msg - WhatsApp message object
|
|
12
|
+
*/
|
|
13
|
+
export async function trackForwardedMessage(msg) {
|
|
14
|
+
try {
|
|
15
|
+
// Check if this is a forwarded message
|
|
16
|
+
const contextInfo =
|
|
17
|
+
msg.message?.extendedTextMessage?.contextInfo ||
|
|
18
|
+
msg.message?.imageMessage?.contextInfo ||
|
|
19
|
+
msg.message?.videoMessage?.contextInfo ||
|
|
20
|
+
msg.message?.documentMessage?.contextInfo ||
|
|
21
|
+
msg.message?.audioMessage?.contextInfo;
|
|
22
|
+
|
|
23
|
+
// A message is considered forwarded if it has isForwarded flag or forwardingScore > 0
|
|
24
|
+
const isForwarded = contextInfo?.isForwarded || (contextInfo?.forwardingScore && contextInfo.forwardingScore > 0);
|
|
25
|
+
|
|
26
|
+
if (!isForwarded) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const messageId = msg.key.id;
|
|
31
|
+
const groupId = msg.key.remoteJid;
|
|
32
|
+
const senderId = normalizeJid(msg.key.participant || msg.key.remoteJid);
|
|
33
|
+
|
|
34
|
+
// Only track forwarded messages in groups
|
|
35
|
+
if (!groupId.endsWith("@g.us")) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Increment the forward score
|
|
40
|
+
const newScore = incrementForwardScore(messageId, groupId, senderId);
|
|
41
|
+
|
|
42
|
+
logger.info(`[Forward Tracking] Message ${messageId} forwarded. New score: ${newScore}`);
|
|
43
|
+
|
|
44
|
+
return true;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
logger.error({ err: error }, "Error tracking forwarded message");
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|