levix-bot 3.3.0-rc.2 → 4.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 (118) hide show
  1. package/README.md +13 -9
  2. package/app.cjs +49 -46
  3. package/package.json +9 -1
  4. package/public/dashboard/assets/index-BBBXWzM0.js +391 -0
  5. package/public/dashboard/assets/index-pYaivHON.css +1 -0
  6. package/public/dashboard/index.html +19 -0
  7. package/public/dashboard.css +391 -66
  8. package/public/dashboard.js +593 -42
  9. package/scheduler.cjs +9 -14
  10. package/src/auth/use-database-auth-state.js +2 -4
  11. package/src/bootstrap/core.js +2 -5
  12. package/src/bootstrap/panel.js +10 -5
  13. package/src/cli.js +3 -5
  14. package/src/commands/autoschedule.cjs +1 -4
  15. package/src/commands/blacklist.cjs +22 -20
  16. package/src/commands/block.cjs +2 -4
  17. package/src/commands/calc.cjs +9 -16
  18. package/src/commands/digit.cjs +97 -0
  19. package/src/commands/gemini.cjs +147 -161
  20. package/src/commands/group/add.cjs +7 -16
  21. package/src/commands/group/antiSpam.cjs +5 -11
  22. package/src/commands/group/antilink.cjs +34 -28
  23. package/src/commands/group/approveall.cjs +3 -11
  24. package/src/commands/group/clearwarns.cjs +2 -5
  25. package/src/commands/group/demote.cjs +3 -8
  26. package/src/commands/group/kick.cjs +12 -15
  27. package/src/commands/group/media.cjs +17 -24
  28. package/src/commands/group/members.cjs +1 -2
  29. package/src/commands/group/promote.cjs +4 -12
  30. package/src/commands/group/removeall.cjs +2 -5
  31. package/src/commands/group/setpp.cjs +1 -1
  32. package/src/commands/group/setrules.cjs +2 -8
  33. package/src/commands/group/setwarn.cjs +4 -2
  34. package/src/commands/group/tagadmins.cjs +2 -5
  35. package/src/commands/group/warn.cjs +26 -24
  36. package/src/commands/group/warns.cjs +1 -2
  37. package/src/commands/group/welcome.cjs +13 -17
  38. package/src/commands/group.cjs +7 -10
  39. package/src/commands/help.cjs +6 -12
  40. package/src/commands/lang.cjs +104 -0
  41. package/src/commands/listschedules.cjs +2 -6
  42. package/src/commands/loop.cjs +6 -13
  43. package/src/commands/memory.cjs +19 -32
  44. package/src/commands/mod.cjs +97 -35
  45. package/src/commands/perm.cjs +18 -56
  46. package/src/commands/ping.cjs +1 -5
  47. package/src/commands/poll.cjs +1 -1
  48. package/src/commands/rand.cjs +2 -5
  49. package/src/commands/schedule.cjs +4 -8
  50. package/src/commands/score.cjs +8 -2
  51. package/src/commands/setprefix.cjs +2 -2
  52. package/src/commands/shortlink.cjs +7 -15
  53. package/src/commands/shutdown.cjs +1 -1
  54. package/src/commands/sticker.cjs +222 -0
  55. package/src/commands/stt.cjs +136 -68
  56. package/src/commands/todo.cjs +9 -15
  57. package/src/commands/tts.cjs +10 -16
  58. package/src/commands/unblock.cjs +2 -6
  59. package/src/commands/weather.cjs +5 -12
  60. package/src/config/ai-persona.md +5 -3
  61. package/src/config/available-models.json +1156 -0
  62. package/src/config/baileys.config.js +2 -1
  63. package/src/config/brand.esm.js +1 -10
  64. package/src/config/constants.js +1 -1
  65. package/src/config/defaults.cjs +66 -66
  66. package/src/config/lock.cjs +2 -2
  67. package/src/config/modelCapabilities.cjs +172 -0
  68. package/src/config/runtime-config.cjs +8 -13
  69. package/src/config/secrets.cjs +18 -10
  70. package/src/config/settings.cjs +94 -10
  71. package/src/config/vault.cjs +107 -0
  72. package/src/core/connection.js +13 -12
  73. package/src/core/events.js +41 -23
  74. package/src/core/proxy.js +7 -7
  75. package/src/core/session.js +91 -48
  76. package/src/core/socket.js +12 -15
  77. package/src/db/db.cjs +14 -6
  78. package/src/db/store.cjs +221 -87
  79. package/src/domain/caddy.js +1 -1
  80. package/src/domain/command.js +40 -19
  81. package/src/domain/detect.js +1 -1
  82. package/src/domain/system.js +2 -5
  83. package/src/handlers/command.handler.js +22 -43
  84. package/src/handlers/group.handler.js +13 -13
  85. package/src/handlers/message.handler.js +11 -21
  86. package/src/index.js +8 -4
  87. package/src/middleware/antispam.middleware.js +46 -23
  88. package/src/middleware/blacklist.middleware.js +14 -9
  89. package/src/middleware/forward-tracking.middleware.js +3 -2
  90. package/src/middleware/permissions.middleware.js +7 -10
  91. package/src/panel/login-throttle.cjs +43 -0
  92. package/src/panel/session-auth.cjs +17 -0
  93. package/src/routes/dashboard.api.esm.js +394 -72
  94. package/src/services/aiAgent.cjs +136 -49
  95. package/src/services/aiProviders.cjs +193 -47
  96. package/src/services/aiRouter.cjs +137 -0
  97. package/src/services/aiTools.cjs +305 -128
  98. package/src/services/llmDiscovery.cjs +481 -0
  99. package/src/services/modelRegistry.cjs +412 -0
  100. package/src/utils/datetime.cjs +2 -2
  101. package/src/utils/lid-helper.esm.js +7 -7
  102. package/src/utils/logger.cjs +1 -1
  103. package/src/utils/markdownParser.cjs +159 -0
  104. package/src/utils/memory.cjs +43 -13
  105. package/src/utils/messageContent.cjs +40 -0
  106. package/src/utils/normalizeJid.esm.js +7 -7
  107. package/src/utils/numerals.cjs +75 -0
  108. package/src/utils/permissions.esm.js +10 -15
  109. package/src/utils/providerUrl.cjs +281 -0
  110. package/src/utils/recurrence.cjs +7 -11
  111. package/src/utils/sendBotMessage.esm.js +4 -14
  112. package/src/utils/statusMessage.cjs +5 -13
  113. package/src/utils/storage.cjs +10 -0
  114. package/src/utils/storage.esm.js +11 -0
  115. package/src/utils/textDecode.cjs +4 -9
  116. package/src/utils/thumbnail.cjs +8 -14
  117. package/src/utils/typing.esm.js +12 -12
  118. package/views/dashboard.ejs +134 -106
@@ -13,6 +13,7 @@
13
13
  // isn't allowed to do it.
14
14
 
15
15
  const dns = require("node:dns").promises;
16
+ const https = require("node:https");
16
17
  const net = require("node:net");
17
18
 
18
19
  const axios = require("axios");
@@ -20,11 +21,8 @@ const axios = require("axios");
20
21
  const logger = require("../utils/logger.cjs");
21
22
  const memory = require("../utils/memory.cjs");
22
23
  const { decodeBuffer, stripHtml, decodeText } = require("../utils/textDecode.cjs");
23
- const {
24
- grantRole,
25
- revokeRole,
26
- listRoles,
27
- } = require("../utils/permissions.cjs");
24
+ const { grantRole, revokeRole, listRoles } = require("../utils/permissions.cjs");
25
+ const { isForbiddenIp } = require("../utils/providerUrl.cjs");
28
26
 
29
27
  // @google/genai exports a `Type` enum (the old SDK called it `SchemaType`).
30
28
  // Its members are the uppercase wire values — Type.STRING === "STRING" — so the
@@ -83,16 +81,11 @@ async function searchDuckDuckGo(query, limit) {
83
81
  validateStatus: (status) => status >= 200 && status < 500,
84
82
  });
85
83
 
86
- const html = decodeBuffer(
87
- Buffer.from(response.data),
88
- response.headers["content-type"]
89
- );
84
+ const html = decodeBuffer(Buffer.from(response.data), response.headers["content-type"]);
90
85
 
91
86
  const results = [];
92
- const linkRe =
93
- /<a[^>]+class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
94
- const snippetRe =
95
- /<a[^>]+class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>/gi;
87
+ const linkRe = /<a[^>]+class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
88
+ const snippetRe = /<a[^>]+class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>/gi;
96
89
 
97
90
  const snippets = [];
98
91
  let snippetMatch;
@@ -114,18 +107,15 @@ async function searchDuckDuckGo(query, limit) {
114
107
  }
115
108
 
116
109
  async function searchGoogleCse(query, limit) {
117
- const response = await axios.get(
118
- "https://www.googleapis.com/customsearch/v1",
119
- {
120
- params: {
121
- key: settings.get("google_search_api_key"),
122
- cx: settings.get("google_search_cx"),
123
- q: query,
124
- num: Math.min(10, limit),
125
- },
126
- timeout: 20000,
127
- }
128
- );
110
+ const response = await axios.get("https://www.googleapis.com/customsearch/v1", {
111
+ params: {
112
+ key: settings.get("google_search_api_key"),
113
+ cx: settings.get("google_search_cx"),
114
+ q: query,
115
+ num: Math.min(10, limit),
116
+ },
117
+ timeout: 20000,
118
+ });
129
119
  return (response.data?.items || []).slice(0, limit).map((item) => ({
130
120
  title: item.title,
131
121
  url: item.link,
@@ -147,6 +137,85 @@ async function webSearch(query, limit = 5) {
147
137
  return searchDuckDuckGo(query, limit);
148
138
  }
149
139
 
140
+ async function searchNews(query, limit = 5) {
141
+ const newsQuery = `${query} news أخبار`;
142
+ return webSearch(newsQuery, limit);
143
+ }
144
+
145
+ async function enrichWikiResults(lang, titles, urls, limit) {
146
+ const count = Math.min(titles?.length || 0, limit);
147
+ const items = [];
148
+ for (let i = 0; i < count; i++) {
149
+ items.push({ title: titles[i], url: urls[i] });
150
+ }
151
+
152
+ const results = await Promise.all(
153
+ items.map(async ({ title, url }) => {
154
+ let extract = "";
155
+ try {
156
+ const summaryRes = await axios.get(
157
+ `https://${lang}.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`,
158
+ {
159
+ timeout: 8000,
160
+ headers: { "User-Agent": "LevixBot/3.4 (https://github.com/Abdodiab2005/levix)" },
161
+ },
162
+ );
163
+ extract = summaryRes.data?.extract || summaryRes.data?.description || "";
164
+ } catch {
165
+ // Non-fatal: summary lookup failure retains title and URL.
166
+ }
167
+ return { title, url, extract: extract.slice(0, 1000) };
168
+ }),
169
+ );
170
+
171
+ return { language: lang, count: results.length, results };
172
+ }
173
+
174
+ async function searchWikipedia(query, language = "auto", limit = 3) {
175
+ let lang = language;
176
+ if (!lang || lang === "auto") {
177
+ lang = /[\u0600-\u06FF]/.test(query) ? "ar" : "en";
178
+ }
179
+ const cleanLang = lang.toLowerCase() === "ar" ? "ar" : "en";
180
+ try {
181
+ const searchRes = await axios.get(`https://${cleanLang}.wikipedia.org/w/api.php`, {
182
+ params: {
183
+ action: "opensearch",
184
+ search: query,
185
+ limit: Math.min(5, Math.max(1, limit)),
186
+ namespace: 0,
187
+ format: "json",
188
+ },
189
+ timeout: 10000,
190
+ headers: { "User-Agent": "LevixBot/3.4 (https://github.com/Abdodiab2005/levix)" },
191
+ });
192
+ const [, titles, , urls] = searchRes.data || [];
193
+ if (!titles || !titles.length) {
194
+ const altLang = cleanLang === "ar" ? "en" : "ar";
195
+ const altRes = await axios.get(`https://${altLang}.wikipedia.org/w/api.php`, {
196
+ params: {
197
+ action: "opensearch",
198
+ search: query,
199
+ limit: Math.min(5, Math.max(1, limit)),
200
+ namespace: 0,
201
+ format: "json",
202
+ },
203
+ timeout: 10000,
204
+ headers: { "User-Agent": "LevixBot/3.4 (https://github.com/Abdodiab2005/levix)" },
205
+ });
206
+ const [, altTitles, , altUrls] = altRes.data || [];
207
+ if (!altTitles || !altTitles.length) {
208
+ return { query, count: 0, results: [], note: "no Wikipedia articles found" };
209
+ }
210
+ return await enrichWikiResults(altLang, altTitles, altUrls, limit);
211
+ }
212
+ return await enrichWikiResults(cleanLang, titles, urls, limit);
213
+ } catch (err) {
214
+ logger.warn({ err: err.message }, "[aiTools] Wikipedia search failed");
215
+ return { error: `Wikipedia search failed: ${err.message}` };
216
+ }
217
+ }
218
+
150
219
  // ---------------------------------------------------------------------------
151
220
  // url fetching
152
221
  // ---------------------------------------------------------------------------
@@ -157,49 +226,9 @@ async function webSearch(query, limit = 5) {
157
226
  // و 2130706433 و [::ffff:127.0.0.1] نفس العنوان بصيغة تانية، والأهم إن رابط
158
227
  // عام ممكن يعمل redirect على 169.254.169.254 (مفاتيح السيرفر عند أغلب
159
228
  // مزودي الاستضافة). فبنـ resolve العنوان ونفحص كل خطوة تحويل لوحدها.
160
- // ::ffff:127.0.0.1 بيتكتب برضه ::ffff:7f00:1 — والـ URL parser بيطبّع الشكل
161
- // التاني، فلازم نفك الصيغة السداسية كمان مش الشكل بالنقط بس.
162
- function mappedIpv4(v6) {
163
- const dotted = /^::(?:ffff:)?(\d+\.\d+\.\d+\.\d+)$/.exec(v6);
164
- if (dotted) return dotted[1];
165
-
166
- const hex = /^::(?:ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(v6);
167
- if (!hex) return null;
168
- const high = parseInt(hex[1], 16);
169
- const low = parseInt(hex[2], 16);
170
- return `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`;
171
- }
172
-
173
- function isPrivateIp(ip) {
174
- const family = net.isIP(ip);
175
-
176
- if (family === 4) {
177
- const [a, b] = ip.split(".").map(Number);
178
- if (a === 0 || a === 10 || a === 127) return true;
179
- if (a === 169 && b === 254) return true; // link-local + metadata
180
- if (a === 172 && b >= 16 && b <= 31) return true;
181
- if (a === 192 && b === 168) return true;
182
- if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
183
- if (a >= 224) return true; // multicast + reserved
184
- return false;
185
- }
186
-
187
- if (family === 6) {
188
- const v6 = ip.toLowerCase();
189
- if (v6 === "::" || v6 === "::1") return true;
190
- if (/^f[cd]/.test(v6)) return true; // unique local
191
- if (v6.startsWith("fe80")) return true; // link local
192
- const mapped = mappedIpv4(v6);
193
- if (mapped) return isPrivateIp(mapped);
194
- return false;
195
- }
196
-
197
- return true; // مش عنوان صالح أصلاً
198
- }
199
-
200
- // ملاحظة: فيه فرصة نظرية إن الـ DNS يرد بعنوان تاني بين الفحص والاتصال
201
- // (DNS rebinding). قفلها بالكامل محتاج تثبيت الـ IP على مستوى الـ socket؛
202
- // اللي هنا بيقفل الحالات العملية: أسماء داخلية، صيغ IP بديلة، والتحويلات.
229
+ // Resolve once, reject every local/reserved answer, then pin the HTTP socket to
230
+ // one of those exact answers. That closes the gap where a rebinding hostname
231
+ // could return a public address during validation and loopback during connect.
203
232
  async function assertPublicUrl(rawUrl) {
204
233
  let url;
205
234
  try {
@@ -207,66 +236,100 @@ async function assertPublicUrl(rawUrl) {
207
236
  } catch {
208
237
  throw new Error("الرابط مش صالح");
209
238
  }
210
- if (!/^https?:$/.test(url.protocol)) throw new Error("مسموح بـ http/https بس");
239
+ if (url.protocol !== "https:") throw new Error("مسموح بروابط HTTPS بس");
211
240
 
212
241
  const host = url.hostname.replace(/^\[|\]$/g, "");
213
242
 
214
243
  if (net.isIP(host)) {
215
- if (isPrivateIp(host)) throw new Error("الرابط ده ممنوع");
216
- return url;
244
+ if (isForbiddenIp(host)) throw new Error("الرابط ده ممنوع");
245
+ return { url, host, addresses: [{ address: host, family: net.isIP(host) }] };
217
246
  }
218
247
 
219
248
  let addresses;
220
249
  try {
221
- addresses = await dns.lookup(host, { all: true });
250
+ addresses = await dns.lookup(host, { all: true, verbatim: true });
222
251
  } catch {
223
252
  throw new Error("مش قادر أوصل للدومين ده");
224
253
  }
225
- if (!addresses.length || addresses.some((a) => isPrivateIp(a.address))) {
254
+ if (!addresses.length || addresses.some((a) => isForbiddenIp(a.address))) {
226
255
  throw new Error("الرابط ده ممنوع");
227
256
  }
228
257
 
229
- return url;
258
+ return { url, host, addresses };
259
+ }
260
+
261
+ function pinnedAgent(expectedHost, addresses) {
262
+ const lookup = (hostname, options, callback) => {
263
+ if (String(hostname).toLowerCase() !== expectedHost.toLowerCase()) {
264
+ const error = new Error("اتصال بدومين غير متوقع");
265
+ error.code = "EAI_FAIL";
266
+ return callback(error);
267
+ }
268
+
269
+ const requestedFamily = typeof options === "number" ? options : options?.family;
270
+ const candidates = requestedFamily
271
+ ? addresses.filter(({ family }) => family === requestedFamily)
272
+ : addresses;
273
+ if (!candidates.length) {
274
+ const error = new Error("مفيش عنوان متوافق للدومين ده");
275
+ error.code = "EAI_ADDRFAMILY";
276
+ return callback(error);
277
+ }
278
+ if (typeof options === "object" && options?.all) {
279
+ return callback(null, candidates.map(({ address, family }) => ({ address, family })));
280
+ }
281
+ return callback(null, candidates[0].address, candidates[0].family);
282
+ };
283
+
284
+ return new https.Agent({ keepAlive: false, lookup });
230
285
  }
231
286
 
232
287
  const MAX_REDIRECTS = 4;
233
288
 
234
289
  async function fetchUrl(rawUrl) {
235
- let url = await assertPublicUrl(rawUrl);
290
+ let target = await assertPublicUrl(rawUrl);
236
291
  let response;
237
292
 
238
293
  for (let hop = 0; ; hop++) {
239
- response = await axios.get(url.toString(), {
240
- responseType: "arraybuffer",
241
- timeout: 20000,
242
- // بنمشي ورا التحويلات بنفسنا عشان نفحص كل خطوة — axios بيفحص الأول بس.
243
- maxRedirects: 0,
244
- maxContentLength: MAX_FETCH_BYTES,
245
- headers: {
246
- "User-Agent": UA,
247
- "Accept-Language": "ar,en;q=0.9",
248
- Accept: "text/html,application/json,text/plain,*/*",
249
- },
250
- validateStatus: (status) => status >= 200 && status < 500,
251
- });
294
+ const { url, host, addresses } = target;
295
+ const agent = pinnedAgent(host, addresses);
296
+ try {
297
+ response = await axios.get(url.toString(), {
298
+ responseType: "arraybuffer",
299
+ timeout: 20000,
300
+ // بنمشي ورا التحويلات بنفسنا عشان نفحص كل خطوة — axios بيفحص الأول بس.
301
+ maxRedirects: 0,
302
+ maxContentLength: MAX_FETCH_BYTES,
303
+ // Ignore process-wide proxy variables: they would perform their own
304
+ // hostname resolution and bypass the pinned lookup above.
305
+ proxy: false,
306
+ httpsAgent: agent,
307
+ headers: {
308
+ "User-Agent": UA,
309
+ "Accept-Language": "ar,en;q=0.9",
310
+ Accept: "text/html,application/json,text/plain,*/*",
311
+ },
312
+ validateStatus: (status) => status >= 200 && status < 500,
313
+ });
314
+ } finally {
315
+ agent.destroy();
316
+ }
252
317
 
253
318
  const location = response.headers?.location;
254
319
  const isRedirect = response.status >= 300 && response.status < 400 && location;
255
320
  if (!isRedirect) break;
256
321
  if (hop >= MAX_REDIRECTS) throw new Error("الرابط بيحوّل كتير أوي");
257
322
 
258
- url = await assertPublicUrl(new URL(location, url).toString());
323
+ target = await assertPublicUrl(new URL(location, url).toString());
259
324
  }
260
325
 
261
326
  const contentType = String(response.headers["content-type"] || "");
262
327
  const body = decodeBuffer(Buffer.from(response.data), contentType);
263
328
 
264
- const text = /json|text\/plain|xml/i.test(contentType)
265
- ? body
266
- : stripHtml(body);
329
+ const text = /json|text\/plain|xml/i.test(contentType) ? body : stripHtml(body);
267
330
 
268
331
  return {
269
- url: url.toString(),
332
+ url: target.url.toString(),
270
333
  status: response.status,
271
334
  contentType,
272
335
  truncated: text.length > MAX_PAGE_CHARS,
@@ -285,16 +348,35 @@ function scopeOf(args) {
285
348
  // نفس قاعدة أمر !memory بالظبط: الكتابة في الذاكرة العامة والمسح محتاجين
286
349
  // أدمن/مالك. الفحص لازم يكون هنا مش في البرومبت — الموديل بيقرا صفحات ويب
287
350
  // ورسايل ناس، وأي واحدة فيهم ممكن تقوله "احفظ ده في الذاكرة العامة".
288
- function isPrivileged(ctx) {
351
+ function isBotPrivileged(ctx) {
289
352
  return Boolean(ctx?.isOwner || ctx?.isAdmin);
290
353
  }
291
354
 
355
+ function isChatMemoryAdmin(ctx) {
356
+ return isBotPrivileged(ctx) || Boolean(ctx?.isGroupAdmin);
357
+ }
358
+
359
+ function roleTargetBound(target, ctx) {
360
+ const raw = String(target || "").trim();
361
+ if (!raw) return false;
362
+ const ids = [...(ctx?.mentionedJids || []), ctx?.quotedParticipant].filter(Boolean);
363
+ const { sameUserSync } = require("../utils/permissions.cjs");
364
+ if (ids.some((id) => sameUserSync(id, raw))) return true;
365
+ const digits = raw.replace(/\D/g, "");
366
+ if (digits.length >= 8 && String(ctx?.userText || "").replace(/\D/g, "").includes(digits)) {
367
+ return true;
368
+ }
369
+ return false;
370
+ }
371
+
292
372
  const NOT_PRIVILEGED = {
293
373
  error: "only the bot owner or an admin can do this",
294
374
  };
295
375
 
296
376
  function preview(value, length = 40) {
297
- const text = String(value || "").replace(/\s+/g, " ").trim();
377
+ const text = String(value || "")
378
+ .replace(/\s+/g, " ")
379
+ .trim();
298
380
  return text.length > length ? `${text.slice(0, length)}...` : text;
299
381
  }
300
382
 
@@ -316,7 +398,8 @@ const TOOLS = {
316
398
  required: ["query"],
317
399
  },
318
400
  },
319
- describe: (args) => `🔍 ببحث عن: ${preview(args?.query, 50)}`,
401
+ describe: (args) => `🔍 جاري البحث عن: ${preview(args?.query, 50)}`,
402
+ describeEn: (args) => `🔍 Searching for: ${preview(args?.query, 50)}`,
320
403
  async run(args) {
321
404
  const query = String(args?.query || "").trim();
322
405
  if (!query) return { error: "query is required" };
@@ -331,20 +414,92 @@ const TOOLS = {
331
414
  },
332
415
  },
333
416
 
417
+ search_wikipedia: {
418
+ declaration: {
419
+ name: "search_wikipedia",
420
+ description:
421
+ "Search Wikipedia for encyclopedic facts, history, science, biographies, definitions, and academic context in Arabic or English.",
422
+ parameters: {
423
+ type: T.OBJECT,
424
+ properties: {
425
+ query: {
426
+ type: T.STRING,
427
+ description: "The topic, entity, or term to look up.",
428
+ },
429
+ language: {
430
+ type: T.STRING,
431
+ description:
432
+ "'ar' for Arabic Wikipedia, 'en' for English Wikipedia, or 'auto' (detect from query).",
433
+ },
434
+ limit: {
435
+ type: T.INTEGER,
436
+ description: "How many articles to return (1-5, default 3).",
437
+ },
438
+ },
439
+ required: ["query"],
440
+ },
441
+ },
442
+ describe: (args) => `📚 جاري البحث في ويكيبيديا: ${preview(args?.query, 45)}`,
443
+ describeEn: (args) => `📚 Searching Wikipedia: ${preview(args?.query, 45)}`,
444
+ async run(args) {
445
+ const query = String(args?.query || "").trim();
446
+ if (!query) return { error: "query is required" };
447
+ const limit = Math.min(5, Math.max(1, Number(args?.limit) || 3));
448
+ return searchWikipedia(query, args?.language, limit);
449
+ },
450
+ },
451
+
452
+ search_news: {
453
+ declaration: {
454
+ name: "search_news",
455
+ description:
456
+ "Search recent news headlines, breaking events, press coverage, and current affairs.",
457
+ parameters: {
458
+ type: T.OBJECT,
459
+ properties: {
460
+ query: {
461
+ type: T.STRING,
462
+ description: "The news topic, person, or event to search for.",
463
+ },
464
+ limit: {
465
+ type: T.INTEGER,
466
+ description: "How many news results to return (1-8, default 5).",
467
+ },
468
+ },
469
+ required: ["query"],
470
+ },
471
+ },
472
+ describe: (args) => `📰 جاري البحث عن الأخبار: ${preview(args?.query, 45)}`,
473
+ describeEn: (args) => `📰 Searching news: ${preview(args?.query, 45)}`,
474
+ async run(args) {
475
+ const query = String(args?.query || "").trim();
476
+ if (!query) return { error: "query is required" };
477
+ const limit = Math.min(8, Math.max(1, Number(args?.limit) || 5));
478
+ const results = await searchNews(query, limit);
479
+ return {
480
+ query,
481
+ count: results.length,
482
+ results,
483
+ note: results.length ? undefined : "no recent news found",
484
+ };
485
+ },
486
+ },
487
+
334
488
  fetch_url: {
335
489
  declaration: {
336
490
  name: "fetch_url",
337
491
  description:
338
- "Open a URL and read its text content (article, API response, page the user sent). Use after web_search when a snippet is not enough.",
492
+ "Open an HTTPS URL and read its text content (article, API response, page the user sent). Use after web_search when a snippet is not enough.",
339
493
  parameters: {
340
494
  type: T.OBJECT,
341
495
  properties: {
342
- url: { type: T.STRING, description: "Full http(s) URL." },
496
+ url: { type: T.STRING, description: "Full HTTPS URL." },
343
497
  },
344
498
  required: ["url"],
345
499
  },
346
500
  },
347
- describe: (args) => `🌐 بفتح الرابط: ${preview(args?.url, 45)}`,
501
+ describe: (args) => `🌐 جاري فتح الرابط: ${preview(args?.url, 45)}`,
502
+ describeEn: (args) => `🌐 Opening link: ${preview(args?.url, 45)}`,
348
503
  async run(args) {
349
504
  const url = String(args?.url || "").trim();
350
505
  if (!url) return { error: "url is required" };
@@ -362,8 +517,7 @@ const TOOLS = {
362
517
  properties: {
363
518
  content: {
364
519
  type: T.STRING,
365
- description:
366
- "The fact to remember, written as a short standalone sentence.",
520
+ description: "The fact to remember, written as a short standalone sentence.",
367
521
  },
368
522
  scope: {
369
523
  type: T.STRING,
@@ -374,13 +528,17 @@ const TOOLS = {
374
528
  },
375
529
  },
376
530
  describe: (args) =>
377
- `🧠 بحفظ في ${scopeOf(args) === "global" ? "الذاكرة العامة" : "ذاكرة الشات"}: ${preview(
378
- args?.content
531
+ `🧠 جاري الحفظ في ${scopeOf(args) === "global" ? "الذاكرة العامة" : "ذاكرة المحادثة"}: ${preview(
532
+ args?.content,
533
+ )}`,
534
+ describeEn: (args) =>
535
+ `🧠 Saving to ${scopeOf(args) === "global" ? "global" : "chat"} memory: ${preview(
536
+ args?.content,
379
537
  )}`,
380
538
  async run(args, ctx) {
381
539
  const scope = scopeOf(args);
382
540
  // الذاكرة العامة بتتحقن في البرومبت بتاع كل الشاتات، فمش أي حد يكتب فيها.
383
- if (scope === "global" && !isPrivileged(ctx)) {
541
+ if (scope === "global" && !isBotPrivileged(ctx)) {
384
542
  return { ...NOT_PRIVILEGED, saved: false };
385
543
  }
386
544
  const entry = memory.addMemory({
@@ -418,7 +576,8 @@ const TOOLS = {
418
576
  },
419
577
  },
420
578
  },
421
- describe: () => "🧠 بدور في الذاكرة...",
579
+ describe: () => "🧠 جاري البحث في الذاكرة...",
580
+ describeEn: () => "🧠 Searching memory...",
422
581
  async run(args, ctx) {
423
582
  const wanted = String(args?.scope || "chat").toLowerCase();
424
583
  const scopes = wanted === "all" ? ["chat", "global"] : [scopeOf(args)];
@@ -449,10 +608,12 @@ const TOOLS = {
449
608
  required: ["ref"],
450
609
  },
451
610
  },
452
- describe: (args) => `🗑️ بمسح من الذاكرة: ${preview(args?.ref)}`,
611
+ describe: (args) => `🗑️ جاري الحذف من الذاكرة: ${preview(args?.ref)}`,
612
+ describeEn: (args) => `🗑️ Removing from memory: ${preview(args?.ref)}`,
453
613
  async run(args, ctx) {
454
- if (!isPrivileged(ctx)) return { ...NOT_PRIVILEGED, removed: false };
455
614
  const scope = scopeOf(args);
615
+ const allowed = scope === "global" ? isBotPrivileged(ctx) : isChatMemoryAdmin(ctx);
616
+ if (!allowed) return { ...NOT_PRIVILEGED, removed: false };
456
617
  const removed = memory.removeMemory({
457
618
  scope,
458
619
  chatId: ctx.chatId,
@@ -483,18 +644,24 @@ const TOOLS = {
483
644
  },
484
645
  },
485
646
  describe: (args) =>
486
- `🔑 ببص على صلاحيات: ${preview(args?.target, 25)} (${args?.role || "admin"})`,
647
+ `🔑 جاري فحص صلاحيات: ${preview(args?.target, 25)} (${args?.role || "admin"})`,
648
+ describeEn: (args) =>
649
+ `🔑 Checking permissions: ${preview(args?.target, 25)} (${args?.role || "admin"})`,
487
650
  async run(args, ctx) {
488
651
  if (!ctx.isOwner) {
489
652
  return { error: "only the bot owner can grant roles", granted: false };
490
653
  }
491
- const role = String(args?.role || "admin").toLowerCase() === "owner"
492
- ? "owner"
493
- : "admin";
654
+ if (!roleTargetBound(args?.target, ctx)) {
655
+ return {
656
+ error: "mention, quote, or type the target phone number in your own request",
657
+ granted: false,
658
+ };
659
+ }
660
+ const role = String(args?.role || "admin").toLowerCase() === "owner" ? "owner" : "admin";
494
661
  const record = await grantRole(args?.target, role);
495
662
  logger.info(
496
663
  { target: args?.target, role, by: ctx.senderId },
497
- "[aiTools] role granted by the agent"
664
+ "[aiTools] role granted by the agent",
498
665
  );
499
666
  return {
500
667
  granted: true,
@@ -517,14 +684,19 @@ const TOOLS = {
517
684
  required: ["target"],
518
685
  },
519
686
  },
520
- describe: (args) => `🔑 بسحب صلاحية من: ${preview(args?.target, 25)}`,
687
+ describe: (args) => `🔑 جاري سحب الصلاحية من: ${preview(args?.target, 25)}`,
688
+ describeEn: (args) => `🔑 Revoking permissions from: ${preview(args?.target, 25)}`,
521
689
  async run(args, ctx) {
522
690
  if (!ctx.isOwner) {
523
691
  return { error: "only the bot owner can revoke roles", revoked: false };
524
692
  }
525
- const role = String(args?.role || "admin").toLowerCase() === "owner"
526
- ? "owner"
527
- : "admin";
693
+ if (!roleTargetBound(args?.target, ctx)) {
694
+ return {
695
+ error: "mention, quote, or type the target phone number in your own request",
696
+ revoked: false,
697
+ };
698
+ }
699
+ const role = String(args?.role || "admin").toLowerCase() === "owner" ? "owner" : "admin";
528
700
  const record = await revokeRole(args?.target, role);
529
701
  return { revoked: true, role, user: record ? record.jid : args?.target };
530
702
  },
@@ -533,11 +705,11 @@ const TOOLS = {
533
705
  list_roles: {
534
706
  declaration: {
535
707
  name: "list_roles",
536
- description:
537
- "List who currently holds bot owner / admin roles. Owners and admins only.",
708
+ description: "List who currently holds bot owner / admin roles. Owners and admins only.",
538
709
  parameters: { type: T.OBJECT, properties: {} },
539
710
  },
540
- describe: () => "🔑 بجيب قائمة الصلاحيات...",
711
+ describe: () => "🔑 جاري جلب قائمة الصلاحيات...",
712
+ describeEn: () => "🔑 Fetching roles list...",
541
713
  async run(_args, ctx) {
542
714
  if (!ctx.isOwner && !ctx.isAdmin) {
543
715
  return { error: "only owners and admins can list roles" };
@@ -567,7 +739,8 @@ const TOOLS = {
567
739
  },
568
740
  },
569
741
  },
570
- describe: () => "🕒 بشوف الوقت...",
742
+ describe: () => "🕒 جاري التحقق من الوقت الحالي...",
743
+ describeEn: () => "🕒 Checking current time...",
571
744
  async run(args) {
572
745
  const timeZone = args?.timezone || settings.get("bot_timezone");
573
746
  const now = new Date();
@@ -596,13 +769,18 @@ function toolDeclarations() {
596
769
  }
597
770
 
598
771
  /** The status line for a tool call (falls back to the raw name). */
599
- function describeCall(name, args) {
772
+ function describeCall(name, args, lang = settings.get("bot_language")) {
600
773
  const tool = TOOLS[name];
601
- if (!tool) return `⚙️ بشغّل: ${name}`;
774
+ const isEn = lang === "en";
775
+ const fallback = isEn ? `⚙️ Running: ${name}` : `⚙️ جاري تشغيل: ${name}`;
776
+ if (!tool) return fallback;
602
777
  try {
603
- return tool.describe(args) || `⚙️ بشغّل: ${name}`;
778
+ if (isEn && typeof tool.describeEn === "function") {
779
+ return tool.describeEn(args) || fallback;
780
+ }
781
+ return tool.describe(args) || fallback;
604
782
  } catch {
605
- return `⚙️ بشغّل: ${name}`;
783
+ return fallback;
606
784
  }
607
785
  }
608
786
 
@@ -618,10 +796,7 @@ async function runTool(name, args, ctx) {
618
796
  const result = await tool.run(args || {}, ctx || {});
619
797
  return result && typeof result === "object" ? result : { result };
620
798
  } catch (err) {
621
- logger.warn(
622
- { err: err?.message, tool: name },
623
- "[aiTools] tool call failed"
624
- );
799
+ logger.warn({ err: err?.message, tool: name }, "[aiTools] tool call failed");
625
800
  return { error: `${err?.name || "Error"}: ${err?.message || err}` };
626
801
  }
627
802
  }
@@ -632,6 +807,8 @@ module.exports = {
632
807
  describeCall,
633
808
  runTool,
634
809
  webSearch,
810
+ searchNews,
811
+ searchWikipedia,
635
812
  fetchUrl,
636
813
  decodeText,
637
814
  };