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.
Files changed (148) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/SETUP.md +242 -0
  4. package/app.cjs +337 -0
  5. package/bin/levix.js +7 -0
  6. package/deploy/install.sh +182 -0
  7. package/deploy/levix.service +56 -0
  8. package/deploy/nginx/levix.leviro.net.conf +79 -0
  9. package/package.json +78 -0
  10. package/public/brand/apple-touch-icon.png +0 -0
  11. package/public/brand/banner.jpg +0 -0
  12. package/public/brand/banner.webp +0 -0
  13. package/public/brand/icon-16.png +0 -0
  14. package/public/brand/icon-192.png +0 -0
  15. package/public/brand/icon-32.png +0 -0
  16. package/public/brand/icon-512.png +0 -0
  17. package/public/brand/icon-512.webp +0 -0
  18. package/public/brand/mark.png +0 -0
  19. package/public/brand/mark.svg +16 -0
  20. package/public/brand/mark.webp +0 -0
  21. package/public/brand/wordmark.png +0 -0
  22. package/public/brand/wordmark.webp +0 -0
  23. package/public/dashboard.css +881 -0
  24. package/public/dashboard.js +1057 -0
  25. package/public/logo.svg +16 -0
  26. package/public/qrcode.min.js +1 -0
  27. package/public/socket.io.min.js +7 -0
  28. package/scheduler.cjs +197 -0
  29. package/src/auth/auth-storage.cjs +74 -0
  30. package/src/auth/use-database-auth-state.js +130 -0
  31. package/src/bootstrap/core.js +74 -0
  32. package/src/bootstrap/events.cjs +43 -0
  33. package/src/bootstrap/panel.js +97 -0
  34. package/src/cli.js +153 -0
  35. package/src/commands/autoschedule.cjs +84 -0
  36. package/src/commands/blacklist.cjs +116 -0
  37. package/src/commands/block.cjs +70 -0
  38. package/src/commands/calc.cjs +387 -0
  39. package/src/commands/debt.cjs +175 -0
  40. package/src/commands/deleteschedule.cjs +36 -0
  41. package/src/commands/gemini.cjs +727 -0
  42. package/src/commands/group/add.cjs +136 -0
  43. package/src/commands/group/all.cjs +43 -0
  44. package/src/commands/group/antiSpam.cjs +96 -0
  45. package/src/commands/group/antilink.cjs +167 -0
  46. package/src/commands/group/approveall.cjs +54 -0
  47. package/src/commands/group/clearwarns.cjs +41 -0
  48. package/src/commands/group/deletenote.cjs +33 -0
  49. package/src/commands/group/demote.cjs +62 -0
  50. package/src/commands/group/kick.cjs +85 -0
  51. package/src/commands/group/media.cjs +120 -0
  52. package/src/commands/group/members.cjs +59 -0
  53. package/src/commands/group/note.cjs +32 -0
  54. package/src/commands/group/promote.cjs +60 -0
  55. package/src/commands/group/removeall.cjs +42 -0
  56. package/src/commands/group/rules.cjs +31 -0
  57. package/src/commands/group/save.cjs +36 -0
  58. package/src/commands/group/setname.cjs +41 -0
  59. package/src/commands/group/setpp.cjs +51 -0
  60. package/src/commands/group/setrules.cjs +46 -0
  61. package/src/commands/group/setwarn.cjs +58 -0
  62. package/src/commands/group/tagadmins.cjs +50 -0
  63. package/src/commands/group/warn.cjs +105 -0
  64. package/src/commands/group/warns.cjs +66 -0
  65. package/src/commands/group/welcome.cjs +109 -0
  66. package/src/commands/group.cjs +160 -0
  67. package/src/commands/help.cjs +230 -0
  68. package/src/commands/listschedules.cjs +47 -0
  69. package/src/commands/loop.cjs +163 -0
  70. package/src/commands/memory.cjs +201 -0
  71. package/src/commands/mod.cjs +155 -0
  72. package/src/commands/notes.cjs +28 -0
  73. package/src/commands/perm.cjs +236 -0
  74. package/src/commands/ping.cjs +17 -0
  75. package/src/commands/poll.cjs +80 -0
  76. package/src/commands/prayer.cjs +64 -0
  77. package/src/commands/qr.cjs +39 -0
  78. package/src/commands/rand.cjs +130 -0
  79. package/src/commands/restart.cjs +25 -0
  80. package/src/commands/schedule.cjs +86 -0
  81. package/src/commands/score.cjs +130 -0
  82. package/src/commands/setprefix.cjs +40 -0
  83. package/src/commands/shortlink.cjs +74 -0
  84. package/src/commands/shutdown.cjs +45 -0
  85. package/src/commands/status.cjs +75 -0
  86. package/src/commands/stt.cjs +150 -0
  87. package/src/commands/todo.cjs +88 -0
  88. package/src/commands/tts.cjs +170 -0
  89. package/src/commands/unblock.cjs +57 -0
  90. package/src/commands/weather.cjs +79 -0
  91. package/src/config/ai-persona.md +17 -0
  92. package/src/config/baileys.config.js +51 -0
  93. package/src/config/brand.cjs +36 -0
  94. package/src/config/brand.esm.js +17 -0
  95. package/src/config/constants.js +16 -0
  96. package/src/config/defaults.cjs +91 -0
  97. package/src/config/lock.cjs +110 -0
  98. package/src/config/paths.cjs +90 -0
  99. package/src/config/runtime-config.cjs +219 -0
  100. package/src/config/secrets.cjs +130 -0
  101. package/src/config/settings.cjs +386 -0
  102. package/src/core/connection.js +199 -0
  103. package/src/core/events.js +61 -0
  104. package/src/core/socket.js +64 -0
  105. package/src/db/db.cjs +282 -0
  106. package/src/db/store.cjs +837 -0
  107. package/src/db/store.esm.js +17 -0
  108. package/src/domain/caddy.js +120 -0
  109. package/src/domain/command.js +380 -0
  110. package/src/domain/detect.js +173 -0
  111. package/src/domain/nginx.js +138 -0
  112. package/src/domain/system.js +148 -0
  113. package/src/handlers/command.handler.js +414 -0
  114. package/src/handlers/group.handler.js +73 -0
  115. package/src/handlers/message.handler.js +149 -0
  116. package/src/index.js +217 -0
  117. package/src/middleware/antispam.middleware.js +68 -0
  118. package/src/middleware/blacklist.middleware.js +29 -0
  119. package/src/middleware/forward-tracking.middleware.js +49 -0
  120. package/src/middleware/permissions.middleware.js +114 -0
  121. package/src/routes/dashboard.api.esm.js +779 -0
  122. package/src/services/aiAgent.cjs +317 -0
  123. package/src/services/aiTools.cjs +635 -0
  124. package/src/utils/datetime.cjs +61 -0
  125. package/src/utils/lid-helper.esm.js +143 -0
  126. package/src/utils/logger.cjs +72 -0
  127. package/src/utils/memory.cjs +385 -0
  128. package/src/utils/normalizeJid.cjs +65 -0
  129. package/src/utils/normalizeJid.esm.js +37 -0
  130. package/src/utils/openBrowser.cjs +129 -0
  131. package/src/utils/permissions.cjs +154 -0
  132. package/src/utils/permissions.esm.js +375 -0
  133. package/src/utils/recentMessageCache.esm.js +70 -0
  134. package/src/utils/requestOrigin.cjs +102 -0
  135. package/src/utils/sendBotMessage.cjs +43 -0
  136. package/src/utils/sendBotMessage.esm.js +139 -0
  137. package/src/utils/statusMessage.cjs +179 -0
  138. package/src/utils/storage-hub.cjs +49 -0
  139. package/src/utils/storage-hub.esm.js +24 -0
  140. package/src/utils/storage.cjs +80 -0
  141. package/src/utils/storage.esm.js +88 -0
  142. package/src/utils/textDecode.cjs +197 -0
  143. package/src/utils/thumbnail.cjs +591 -0
  144. package/src/utils/typing.esm.js +102 -0
  145. package/views/dashboard.ejs +329 -0
  146. package/views/login.ejs +105 -0
  147. package/views/qr.ejs +108 -0
  148. 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
+ }