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,143 @@
1
+ // Baileys v7 LID Helper Functions
2
+ // These functions help work with the new LID system
3
+
4
+ import { getLidForPn, getPnForLid } from '../utils/storage.esm.js';
5
+
6
+ /**
7
+ * Get the preferred identifier for a user (LID if available, otherwise PN)
8
+ * @param {Object} sock - WhatsApp socket instance
9
+ * @param {string} pn - Phone number JID (e.g., "1234567890@s.whatsapp.net")
10
+ * @returns {Promise<string>} - LID if available, otherwise the original PN
11
+ */
12
+ export async function getPreferredJid(sock, pn) {
13
+ if (!pn || !pn.includes('@s.whatsapp.net')) {
14
+ return pn; // Not a phone number, return as-is
15
+ }
16
+
17
+ try {
18
+ // First check database
19
+ const lidFromDb = getLidForPn(pn);
20
+ if (lidFromDb) {
21
+ return lidFromDb;
22
+ }
23
+
24
+ // Try to get from Baileys' internal store if available
25
+ if (sock.signalRepository?.lidMapping) {
26
+ const lid = await sock.signalRepository.lidMapping.getLIDForPN(pn);
27
+ if (lid) {
28
+ return lid;
29
+ }
30
+ }
31
+
32
+ // Fallback to PN
33
+ return pn;
34
+ } catch (error) {
35
+ // If any error, return the original PN
36
+ return pn;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Convert LID to PN if needed
42
+ * @param {Object} sock - WhatsApp socket instance
43
+ * @param {string} lid - LID JID (e.g., "lid@lid")
44
+ * @returns {Promise<string>} - PN if found, otherwise the original LID
45
+ */
46
+ export async function lidToPn(sock, lid) {
47
+ if (!lid || !lid.includes('@lid')) {
48
+ return lid; // Not a LID, return as-is
49
+ }
50
+
51
+ try {
52
+ // First check database
53
+ const pnFromDb = getPnForLid(lid);
54
+ if (pnFromDb) {
55
+ return pnFromDb;
56
+ }
57
+
58
+ // Try to get from Baileys' internal store if available
59
+ if (sock.signalRepository?.lidMapping) {
60
+ const pn = await sock.signalRepository.lidMapping.getPNForLID(lid);
61
+ if (pn) {
62
+ return pn;
63
+ }
64
+ }
65
+
66
+ // Fallback to LID
67
+ return lid;
68
+ } catch (error) {
69
+ return lid;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Get user identifier from message key
75
+ * Returns LID if available, otherwise PN
76
+ * @param {Object} sock - WhatsApp socket instance
77
+ * @param {Object} msgKey - Message key object
78
+ * @returns {Promise<string>} - User identifier (LID or PN)
79
+ */
80
+ export async function getUserIdFromKey(sock, msgKey) {
81
+ const isGroup = msgKey.remoteJid?.endsWith('@g.us');
82
+
83
+ if (isGroup) {
84
+ // In groups, use participant field
85
+ const participantId = msgKey.participant || msgKey.remoteJid;
86
+
87
+ // V7: Check for participantAlt (the alternate JID)
88
+ if (msgKey.participantAlt) {
89
+ // If we have participantAlt, the participant is likely a LID
90
+ // Return the LID for privacy
91
+ return participantId;
92
+ }
93
+
94
+ return participantId;
95
+ } else {
96
+ // In DMs, use remoteJid
97
+ const remoteId = msgKey.remoteJid;
98
+
99
+ // V7: Check for remoteJidAlt
100
+ if (msgKey.remoteJidAlt) {
101
+ // If we have remoteJidAlt, the remoteJid is likely a LID
102
+ return remoteId;
103
+ }
104
+
105
+ return remoteId;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Format user mention using LID when available
111
+ * @param {Object} sock - WhatsApp socket instance
112
+ * @param {string} jid - User JID (LID or PN)
113
+ * @returns {Promise<Object>} - Object with mention text and mentions array
114
+ */
115
+ export async function formatUserMention(sock, jid) {
116
+ // Extract the number/ID part for display
117
+ const displayId = jid.split('@')[0];
118
+
119
+ return {
120
+ text: `@${displayId}`,
121
+ mentions: [jid]
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Resolve multiple JIDs to their preferred format (LID when available)
127
+ * @param {Object} sock - WhatsApp socket instance
128
+ * @param {Array<string>} jids - Array of JIDs
129
+ * @returns {Promise<Array<string>>} - Array of resolved JIDs
130
+ */
131
+ export async function resolveJids(sock, jids) {
132
+ const resolved = [];
133
+
134
+ for (const jid of jids) {
135
+ if (jid.includes('@s.whatsapp.net')) {
136
+ resolved.push(await getPreferredJid(sock, jid));
137
+ } else {
138
+ resolved.push(jid);
139
+ }
140
+ }
141
+
142
+ return resolved;
143
+ }
@@ -0,0 +1,72 @@
1
+ const path = require("path");
2
+ const pino = require("pino");
3
+
4
+ const { ensureDataDir } = require("../config/paths.cjs");
5
+
6
+ // Logs go in the data directory with everything else the bot writes, so a
7
+ // global install never tries to write next to its own code.
8
+ const logDir = ensureDataDir("logs");
9
+
10
+ // Pino's `transport` option runs each target in a worker thread, and a worker
11
+ // loads its target by module path — which a single-file executable can't
12
+ // provide. Same three destinations either way; packaged builds just wire them
13
+ // as plain streams in this thread.
14
+ function isPackaged() {
15
+ try {
16
+ return require("node:sea").isSea() === true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ const base = {
23
+ level: "trace",
24
+ serializers: {
25
+ err: pino.stdSerializers.err,
26
+ error: pino.stdSerializers.err,
27
+ },
28
+ };
29
+
30
+ const PRETTY = {
31
+ colorize: true,
32
+ translateTime: "SYS:dd-mm-yyyy HH:MM:ss",
33
+ ignore: "pid,hostname",
34
+ messageFormat: "{msg}",
35
+ errorLikeObjectKeys: ["err", "error"],
36
+ };
37
+
38
+ const combined = path.join(logDir, "combined.log");
39
+ const errors = path.join(logDir, "error.log");
40
+
41
+ const logger = isPackaged()
42
+ ? pino(
43
+ base,
44
+ pino.multistream([
45
+ { level: "trace", stream: pino.destination({ dest: combined, mkdir: true }) },
46
+ { level: "error", stream: pino.destination({ dest: errors, mkdir: true }) },
47
+ { level: "trace", stream: require("pino-pretty")(PRETTY) },
48
+ ])
49
+ )
50
+ : pino({
51
+ ...base,
52
+ transport: {
53
+ targets: [
54
+ // Combined log file (all levels)
55
+ {
56
+ target: "pino/file",
57
+ level: "trace",
58
+ options: { destination: combined, mkdir: true },
59
+ },
60
+ // Error log file (errors only)
61
+ {
62
+ target: "pino/file",
63
+ level: "error",
64
+ options: { destination: errors, mkdir: true },
65
+ },
66
+ // Pretty console output
67
+ { target: "pino-pretty", level: "trace", options: PRETTY },
68
+ ],
69
+ },
70
+ });
71
+
72
+ module.exports = logger;
@@ -0,0 +1,385 @@
1
+ // Long-term memory for the AI, stored as plain Markdown files.
2
+ //
3
+ // Why files and not a table
4
+ // -------------------------
5
+ // The operator asked for "save it to your memory" to end up in a `.md` file
6
+ // per chat (plus one global file) — the same idea as an agent's CLAUDE.md.
7
+ // Markdown keeps the memory:
8
+ // * human readable and hand-editable (open the file, fix a line, done),
9
+ // * diffable / backup-able without a DB dump,
10
+ // * trivially injectable into the model's system prompt.
11
+ //
12
+ // Layout (root overridable with MEMORY_DIR):
13
+ //
14
+ // memory/
15
+ // global.md <- shared by every chat
16
+ // chats/
17
+ // 201234567890@s.whatsapp.net.md
18
+ // 120363xxxxxxxxx@g.us.md
19
+ //
20
+ // File shape:
21
+ //
22
+ // # 🧠 ذاكرة ...
23
+ // <!-- chat: <jid> -->
24
+ // ...free-form prose the human wrote (never touched by the bot)...
25
+ // - fact one <!-- id:m1a2b3 at:2026-08-21T10:00:00.000Z by:فلان -->
26
+ // - fact two
27
+ //
28
+ // Every line starting with `-` (or `*`) is one memory entry; indented lines
29
+ // continue the entry above. Metadata rides in a trailing HTML comment, which
30
+ // Markdown renderers hide and humans can delete without breaking anything —
31
+ // entries without it still load fine.
32
+ //
33
+ // CommonJS because the commands and the AI agent are.
34
+
35
+ const fs = require("fs");
36
+ const path = require("path");
37
+
38
+ const logger = require("./logger.cjs");
39
+
40
+ const { dataPath, PACKAGE_ROOT } = require("../config/paths.cjs");
41
+
42
+ // The memory files live in the data directory. An install that predates that
43
+ // keeps using its existing ./memory folder rather than silently starting empty.
44
+ const LEGACY_ROOT = path.join(PACKAGE_ROOT, "memory");
45
+ const ROOT = fs.existsSync(LEGACY_ROOT) ? LEGACY_ROOT : dataPath("memory");
46
+ const CHATS_DIR = path.join(ROOT, "chats");
47
+ const GLOBAL_FILE = path.join(ROOT, "global.md");
48
+
49
+ // Guardrails so a runaway agent can't grow the prompt without bound.
50
+ const MAX_ENTRY_CHARS = 2000;
51
+ // Read per call so the dashboard can change it without a restart.
52
+ const settings = require("../config/settings.cjs");
53
+ const maxContextChars = () => settings.get("memory_context_chars");
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // paths
57
+ // ---------------------------------------------------------------------------
58
+
59
+ function ensureDirs() {
60
+ fs.mkdirSync(CHATS_DIR, { recursive: true });
61
+ }
62
+
63
+ /** `120363@g.us` -> a filename that is safe on every OS we might run on. */
64
+ function safeFileName(chatId) {
65
+ return String(chatId || "unknown")
66
+ .replace(/[^A-Za-z0-9._@-]+/g, "_")
67
+ .slice(0, 120);
68
+ }
69
+
70
+ function normalizeScope(scope) {
71
+ const value = String(scope || "chat").toLowerCase();
72
+ if (["global", "عام", "all", "عالمي", "shared"].includes(value)) return "global";
73
+ return "chat";
74
+ }
75
+
76
+ /** Absolute path of the markdown file backing a scope. */
77
+ function memoryFilePath(scope, chatId) {
78
+ if (normalizeScope(scope) === "global") return GLOBAL_FILE;
79
+ if (!chatId) return GLOBAL_FILE;
80
+ return path.join(CHATS_DIR, `${safeFileName(chatId)}.md`);
81
+ }
82
+
83
+ function headerFor(scope, chatId, chatName) {
84
+ if (normalizeScope(scope) === "global") {
85
+ return [
86
+ "# 🧠 الذاكرة العامة",
87
+ "",
88
+ "> معلومات محفوظة لكل المحادثات. ملف Markdown عادي — عدّله بإيدك وقت ما تحب.",
89
+ "> كل سطر بيبدأ بـ `-` هو معلومة محفوظة.",
90
+ "",
91
+ "",
92
+ ].join("\n");
93
+ }
94
+ return [
95
+ "# 🧠 ذاكرة المحادثة",
96
+ "",
97
+ `<!-- chat: ${chatId || "unknown"} -->`,
98
+ chatName ? `<!-- name: ${chatName} -->` : null,
99
+ "",
100
+ "> معلومات محفوظة لهذه المحادثة فقط. ملف Markdown عادي — عدّله بإيدك وقت ما تحب.",
101
+ "> كل سطر بيبدأ بـ `-` هو معلومة محفوظة.",
102
+ "",
103
+ "",
104
+ ]
105
+ .filter((line) => line !== null)
106
+ .join("\n");
107
+ }
108
+
109
+ function readRaw(filePath) {
110
+ try {
111
+ return fs.readFileSync(filePath, "utf8");
112
+ } catch {
113
+ return "";
114
+ }
115
+ }
116
+
117
+ function writeRaw(filePath, content) {
118
+ ensureDirs();
119
+ fs.writeFileSync(filePath, content, "utf8");
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // parsing
124
+ // ---------------------------------------------------------------------------
125
+
126
+ const BULLET_RE = /^\s{0,3}[-*]\s+(.*)$/;
127
+ const META_RE = /\s*<!--\s*(id:[^>]*?)\s*-->\s*$/;
128
+
129
+ function parseMeta(line) {
130
+ const match = line.match(META_RE);
131
+ if (!match) return { text: line, meta: {} };
132
+ const meta = {};
133
+ for (const pair of match[1].split(/\s+/)) {
134
+ const idx = pair.indexOf(":");
135
+ if (idx > 0) meta[pair.slice(0, idx)] = pair.slice(idx + 1);
136
+ }
137
+ return { text: line.replace(META_RE, ""), meta };
138
+ }
139
+
140
+ /**
141
+ * Every entry in a memory file, in file order.
142
+ * @returns {Array<{id:string, at:string, by:string, content:string, start:number, end:number}>}
143
+ */
144
+ function parseEntries(raw) {
145
+ const lines = raw.split("\n");
146
+ const entries = [];
147
+ let current = null;
148
+
149
+ const flush = () => {
150
+ if (!current) return;
151
+ current.content = current.content.trim();
152
+ if (current.content) entries.push(current);
153
+ current = null;
154
+ };
155
+
156
+ lines.forEach((line, index) => {
157
+ const bullet = line.match(BULLET_RE);
158
+ if (bullet) {
159
+ flush();
160
+ const { text, meta } = parseMeta(bullet[1]);
161
+ current = {
162
+ id: meta.id || `m${index}`,
163
+ at: meta.at || "",
164
+ by: meta.by ? meta.by.replace(/_/g, " ") : "",
165
+ content: text.trim(),
166
+ start: index,
167
+ end: index,
168
+ };
169
+ return;
170
+ }
171
+ // Continuation of the previous bullet (indented, non-empty).
172
+ if (current && /^\s{2,}\S/.test(line)) {
173
+ const { text } = parseMeta(line);
174
+ current.content += `\n${text.trim()}`;
175
+ current.end = index;
176
+ return;
177
+ }
178
+ flush();
179
+ });
180
+ flush();
181
+
182
+ return entries;
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // public API
187
+ // ---------------------------------------------------------------------------
188
+
189
+ function newId() {
190
+ return `m${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
191
+ }
192
+
193
+ /** All entries for a scope. Never throws — a missing file is an empty memory. */
194
+ function listMemory({ scope = "chat", chatId } = {}) {
195
+ try {
196
+ return parseEntries(readRaw(memoryFilePath(scope, chatId)));
197
+ } catch (err) {
198
+ logger.error({ err }, "[memory] failed to read memory");
199
+ return [];
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Append one memory entry. Appending (rather than rewriting the file) is what
205
+ * keeps any prose the human added by hand intact.
206
+ *
207
+ * @returns {{id:string, content:string, scope:string, file:string}}
208
+ */
209
+ function addMemory({ scope = "chat", chatId, content, by, chatName } = {}) {
210
+ const normalizedScope = normalizeScope(scope);
211
+ const text = String(content || "").trim().slice(0, MAX_ENTRY_CHARS);
212
+ if (!text) throw new Error("مفيش محتوى أحفظه");
213
+
214
+ const filePath = memoryFilePath(normalizedScope, chatId);
215
+ ensureDirs();
216
+
217
+ let raw = readRaw(filePath);
218
+ if (!raw.trim()) raw = headerFor(normalizedScope, chatId, chatName);
219
+ if (!raw.endsWith("\n")) raw += "\n";
220
+
221
+ const id = newId();
222
+ const meta = [
223
+ `id:${id}`,
224
+ `at:${new Date().toISOString()}`,
225
+ by ? `by:${String(by).replace(/\s+/g, "_").slice(0, 40)}` : null,
226
+ ]
227
+ .filter(Boolean)
228
+ .join(" ");
229
+
230
+ // Multi-line facts stay one Markdown list item: continuation lines are
231
+ // indented so parseEntries() glues them back together.
232
+ const [first, ...rest] = text.split("\n");
233
+ const body = [
234
+ `- ${first.trim()} <!-- ${meta} -->`,
235
+ ...rest.map((line) => ` ${line.trim()}`),
236
+ ].join("\n");
237
+
238
+ writeRaw(filePath, `${raw}${body}\n`);
239
+ logger.info(
240
+ { scope: normalizedScope, chatId, id },
241
+ "[memory] saved a new entry"
242
+ );
243
+ return { id, content: text, scope: normalizedScope, file: filePath };
244
+ }
245
+
246
+ /**
247
+ * Drop one entry, addressed by id, 1-based index, or a text fragment.
248
+ * @returns {object|null} the removed entry
249
+ */
250
+ function removeMemory({ scope = "chat", chatId, ref } = {}) {
251
+ const filePath = memoryFilePath(scope, chatId);
252
+ const raw = readRaw(filePath);
253
+ if (!raw) return null;
254
+
255
+ const entries = parseEntries(raw);
256
+ if (!entries.length) return null;
257
+
258
+ const needle = String(ref ?? "").trim();
259
+ if (!needle) return null;
260
+
261
+ let target =
262
+ entries.find((entry) => entry.id === needle) ||
263
+ (/^\d+$/.test(needle) ? entries[Number(needle) - 1] : null);
264
+
265
+ if (!target) {
266
+ const lower = needle.toLowerCase();
267
+ target = entries.find((entry) => entry.content.toLowerCase().includes(lower));
268
+ }
269
+ if (!target) return null;
270
+
271
+ const lines = raw.split("\n");
272
+ lines.splice(target.start, target.end - target.start + 1);
273
+ writeRaw(filePath, lines.join("\n"));
274
+ return target;
275
+ }
276
+
277
+ /** Wipe every entry but keep whatever prose/header the file already had. */
278
+ function clearMemory({ scope = "chat", chatId } = {}) {
279
+ const filePath = memoryFilePath(scope, chatId);
280
+ const raw = readRaw(filePath);
281
+ if (!raw) return 0;
282
+
283
+ const entries = parseEntries(raw);
284
+ if (!entries.length) return 0;
285
+
286
+ const drop = new Set();
287
+ for (const entry of entries) {
288
+ for (let i = entry.start; i <= entry.end; i++) drop.add(i);
289
+ }
290
+ const kept = raw.split("\n").filter((_, index) => !drop.has(index));
291
+ writeRaw(filePath, kept.join("\n"));
292
+ return entries.length;
293
+ }
294
+
295
+ /** Case-insensitive substring search across one scope. */
296
+ function searchMemory(query, { scope = "chat", chatId } = {}) {
297
+ const needle = String(query || "").trim().toLowerCase();
298
+ const entries = listMemory({ scope, chatId });
299
+ if (!needle) return entries;
300
+ return entries.filter((entry) => entry.content.toLowerCase().includes(needle));
301
+ }
302
+
303
+ function renderEntries(entries, limitChars) {
304
+ const lines = [];
305
+ let used = 0;
306
+ // Newest facts matter most, so fill from the end and restore order after.
307
+ for (let i = entries.length - 1; i >= 0; i--) {
308
+ const line = `- ${entries[i].content.replace(/\n/g, " ")}`;
309
+ if (used + line.length > limitChars) break;
310
+ used += line.length + 1;
311
+ lines.unshift(line);
312
+ }
313
+ return lines.join("\n");
314
+ }
315
+
316
+ /**
317
+ * The memory block injected into the model's system instruction.
318
+ * Returns "" when both files are empty, so a fresh install pays nothing.
319
+ */
320
+ function buildMemoryContext(chatId, { limitChars = maxContextChars() } = {}) {
321
+ const globalEntries = listMemory({ scope: "global" });
322
+ const chatEntries = chatId ? listMemory({ scope: "chat", chatId }) : [];
323
+ if (!globalEntries.length && !chatEntries.length) return "";
324
+
325
+ const perScope = Math.floor(limitChars / (globalEntries.length && chatEntries.length ? 2 : 1));
326
+ const blocks = [];
327
+
328
+ if (globalEntries.length) {
329
+ blocks.push(
330
+ `### ذاكرة عامة (global.md)\n${renderEntries(globalEntries, perScope)}`
331
+ );
332
+ }
333
+ if (chatEntries.length) {
334
+ blocks.push(
335
+ `### ذاكرة المحادثة دي (${safeFileName(chatId)}.md)\n${renderEntries(
336
+ chatEntries,
337
+ perScope
338
+ )}`
339
+ );
340
+ }
341
+
342
+ return [
343
+ "## 🧠 الذاكرة الدائمة",
344
+ "دي معلومات اتحفظت قبل كده. اعتبرها حقيقة واستخدمها من غير ما تقول إنك بتقرأ من ملف.",
345
+ "",
346
+ ...blocks,
347
+ ].join("\n");
348
+ }
349
+
350
+ /** Counts + file paths, for `!memory` and `!status`. */
351
+ function memoryStats(chatId) {
352
+ const globalEntries = listMemory({ scope: "global" });
353
+ const chatEntries = chatId ? listMemory({ scope: "chat", chatId }) : [];
354
+ let chatFiles = 0;
355
+ try {
356
+ chatFiles = fs.readdirSync(CHATS_DIR).filter((f) => f.endsWith(".md")).length;
357
+ } catch {
358
+ chatFiles = 0;
359
+ }
360
+ return {
361
+ root: ROOT,
362
+ globalFile: GLOBAL_FILE,
363
+ chatFile: chatId ? memoryFilePath("chat", chatId) : null,
364
+ globalCount: globalEntries.length,
365
+ chatCount: chatEntries.length,
366
+ chatFiles,
367
+ };
368
+ }
369
+
370
+ module.exports = {
371
+ ROOT,
372
+ CHATS_DIR,
373
+ GLOBAL_FILE,
374
+ ensureDirs,
375
+ normalizeScope,
376
+ memoryFilePath,
377
+ listMemory,
378
+ addMemory,
379
+ removeMemory,
380
+ clearMemory,
381
+ searchMemory,
382
+ buildMemoryContext,
383
+ memoryStats,
384
+ parseEntries,
385
+ };
@@ -0,0 +1,65 @@
1
+ // CommonJS wrapper for normalizeJid.esm.js
2
+ // This allows CommonJS files to use the normalizeJid function
3
+
4
+ // We can't directly require ESM from CommonJS, so we use a workaround
5
+ // by creating a simple normalizeJid function that handles both formats
6
+
7
+ /**
8
+ * V7: Normalize WhatsApp JID to handle both PN (Phone Number) and LID (Local Identifier)
9
+ * @param {string} jid - The JID to normalize
10
+ * @returns {string} - Normalized JID
11
+ */
12
+ function normalizeJid(jid) {
13
+ if (!jid) return jid;
14
+
15
+ // V7: Handle LID format (@lid)
16
+ if (jid.includes("@lid")) {
17
+ // LID format: keep as-is
18
+ if (jid.includes(":")) {
19
+ return jid.split(":")[0] + "@lid";
20
+ }
21
+ return jid;
22
+ }
23
+
24
+ // Handle PN format (@s.whatsapp.net)
25
+ if (jid.includes(":")) {
26
+ return jid.split(":")[0] + "@" + jid.split("@")[1];
27
+ }
28
+
29
+ return jid;
30
+ }
31
+
32
+ /**
33
+ * V7: Helper to check if JID is a Phone Number (PN)
34
+ * @param {string} jid - The JID to check
35
+ * @returns {boolean}
36
+ */
37
+ function isPnJid(jid) {
38
+ if (!jid) return false;
39
+ return jid.includes("@s.whatsapp.net");
40
+ }
41
+
42
+ /**
43
+ * V7: Helper to check if JID is a LID
44
+ * @param {string} jid - The JID to check
45
+ * @returns {boolean}
46
+ */
47
+ function isLidJid(jid) {
48
+ if (!jid) return false;
49
+ return jid.includes("@lid");
50
+ }
51
+
52
+ /**
53
+ * V7: Helper to check if JID is a user (either PN or LID)
54
+ * @param {string} jid - The JID to check
55
+ * @returns {boolean}
56
+ */
57
+ function isUserJid(jid) {
58
+ return isPnJid(jid) || isLidJid(jid);
59
+ }
60
+
61
+ module.exports = normalizeJid;
62
+ module.exports.normalizeJid = normalizeJid;
63
+ module.exports.isPnJid = isPnJid;
64
+ module.exports.isLidJid = isLidJid;
65
+ module.exports.isUserJid = isUserJid;
@@ -0,0 +1,37 @@
1
+ // V7: Updated to handle both PN (Phone Number) and LID (Local Identifier)
2
+ export default function normalizeJid(jid) {
3
+ if (!jid) return jid;
4
+
5
+ // V7: Handle LID format (@lid)
6
+ if (jid.includes('@lid')) {
7
+ // LID format: keep as-is
8
+ if (jid.includes(':')) {
9
+ return jid.split(':')[0] + '@lid';
10
+ }
11
+ return jid;
12
+ }
13
+
14
+ // Handle PN format (@s.whatsapp.net)
15
+ if (jid.includes(':')) {
16
+ return jid.split(':')[0] + '@' + jid.split('@')[1];
17
+ }
18
+
19
+ return jid;
20
+ }
21
+
22
+ // V7: Helper to check if JID is a Phone Number (PN)
23
+ export function isPnJid(jid) {
24
+ if (!jid) return false;
25
+ return jid.includes('@s.whatsapp.net');
26
+ }
27
+
28
+ // V7: Helper to check if JID is a LID
29
+ export function isLidJid(jid) {
30
+ if (!jid) return false;
31
+ return jid.includes('@lid');
32
+ }
33
+
34
+ // V7: Helper to check if JID is a user (either PN or LID)
35
+ export function isUserJid(jid) {
36
+ return isPnJid(jid) || isLidJid(jid);
37
+ }