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,635 @@
1
+ // The tools the AI agent can actually call.
2
+ //
3
+ // Each entry is { declaration, run(args, ctx), describe(args) }:
4
+ // declaration - what Gemini sees (name + JSON schema)
5
+ // run - what happens when the model calls it; ALWAYS resolves to a
6
+ // plain object (errors included) so one bad tool call doesn't
7
+ // kill the turn
8
+ // describe - the Arabic line shown in the live status message while the
9
+ // tool runs ("🔍 ببحث عن: ...")
10
+ //
11
+ // Anything privileged (roles) is gated on the *caller*, not on the model: the
12
+ // agent can ask, but the tool refuses when the person who sent the message
13
+ // isn't allowed to do it.
14
+
15
+ const dns = require("node:dns").promises;
16
+ const net = require("node:net");
17
+
18
+ const axios = require("axios");
19
+
20
+ const logger = require("../utils/logger.cjs");
21
+ const memory = require("../utils/memory.cjs");
22
+ const { decodeBuffer, stripHtml, decodeText } = require("../utils/textDecode.cjs");
23
+ const {
24
+ grantRole,
25
+ revokeRole,
26
+ listRoles,
27
+ } = require("../utils/permissions.cjs");
28
+
29
+ // The SDK exports a SchemaType enum; fall back to the wire values if a future
30
+ // version drops it, so a rename can't take the whole agent down.
31
+ let SchemaType = null;
32
+ try {
33
+ ({ SchemaType } = require("@google/generative-ai"));
34
+ } catch {
35
+ SchemaType = null;
36
+ }
37
+ const T = SchemaType || {
38
+ STRING: "string",
39
+ NUMBER: "number",
40
+ INTEGER: "integer",
41
+ BOOLEAN: "boolean",
42
+ ARRAY: "array",
43
+ OBJECT: "object",
44
+ };
45
+
46
+ const UA =
47
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
48
+
49
+ const settings = require("../config/settings.cjs");
50
+
51
+ const MAX_PAGE_CHARS = 6000;
52
+ const MAX_FETCH_BYTES = 3 * 1024 * 1024;
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // web search
56
+ // ---------------------------------------------------------------------------
57
+
58
+ function unwrapDuckUrl(href) {
59
+ if (!href) return null;
60
+ let url = href.startsWith("//") ? `https:${href}` : href;
61
+ const redirect = /[?&]uddg=([^&]+)/.exec(url);
62
+ if (redirect) {
63
+ try {
64
+ url = decodeURIComponent(redirect[1]);
65
+ } catch {
66
+ // keep the wrapper url
67
+ }
68
+ }
69
+ return url.startsWith("http") ? url : null;
70
+ }
71
+
72
+ async function searchDuckDuckGo(query, limit) {
73
+ const response = await axios.get("https://html.duckduckgo.com/html/", {
74
+ params: { q: query },
75
+ responseType: "arraybuffer",
76
+ timeout: 20000,
77
+ headers: {
78
+ "User-Agent": UA,
79
+ "Accept-Language": "ar,en-US;q=0.9,en;q=0.8",
80
+ },
81
+ validateStatus: (status) => status >= 200 && status < 500,
82
+ });
83
+
84
+ const html = decodeBuffer(
85
+ Buffer.from(response.data),
86
+ response.headers["content-type"]
87
+ );
88
+
89
+ const results = [];
90
+ const linkRe =
91
+ /<a[^>]+class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
92
+ const snippetRe =
93
+ /<a[^>]+class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>/gi;
94
+
95
+ const snippets = [];
96
+ let snippetMatch;
97
+ while ((snippetMatch = snippetRe.exec(html)) !== null) {
98
+ snippets.push(stripHtml(snippetMatch[1]));
99
+ }
100
+
101
+ let match;
102
+ while ((match = linkRe.exec(html)) !== null && results.length < limit) {
103
+ const url = unwrapDuckUrl(match[1]);
104
+ if (!url) continue;
105
+ results.push({
106
+ title: stripHtml(match[2]),
107
+ url,
108
+ snippet: snippets[results.length] || "",
109
+ });
110
+ }
111
+ return results;
112
+ }
113
+
114
+ async function searchGoogleCse(query, limit) {
115
+ const response = await axios.get(
116
+ "https://www.googleapis.com/customsearch/v1",
117
+ {
118
+ params: {
119
+ key: settings.get("google_search_api_key"),
120
+ cx: settings.get("google_search_cx"),
121
+ q: query,
122
+ num: Math.min(10, limit),
123
+ },
124
+ timeout: 20000,
125
+ }
126
+ );
127
+ return (response.data?.items || []).slice(0, limit).map((item) => ({
128
+ title: item.title,
129
+ url: item.link,
130
+ snippet: item.snippet || "",
131
+ }));
132
+ }
133
+
134
+ async function webSearch(query, limit = 5) {
135
+ // Google Programmable Search when the operator configured it, DuckDuckGo
136
+ // otherwise — no key needed, which keeps the feature working out of the box.
137
+ if (settings.get("google_search_api_key") && settings.get("google_search_cx")) {
138
+ try {
139
+ const results = await searchGoogleCse(query, limit);
140
+ if (results.length) return results;
141
+ } catch (err) {
142
+ logger.warn({ err: err.message }, "[aiTools] Google CSE failed");
143
+ }
144
+ }
145
+ return searchDuckDuckGo(query, limit);
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // url fetching
150
+ // ---------------------------------------------------------------------------
151
+
152
+ // The model picks these URLs, so keep it off the machine's own network.
153
+ //
154
+ // الفحص بالاسم لوحده مش كفاية: دومين عادي ممكن يـ resolve على 127.0.0.1،
155
+ // و 2130706433 و [::ffff:127.0.0.1] نفس العنوان بصيغة تانية، والأهم إن رابط
156
+ // عام ممكن يعمل redirect على 169.254.169.254 (مفاتيح السيرفر عند أغلب
157
+ // مزودي الاستضافة). فبنـ resolve العنوان ونفحص كل خطوة تحويل لوحدها.
158
+ // ::ffff:127.0.0.1 بيتكتب برضه ::ffff:7f00:1 — والـ URL parser بيطبّع الشكل
159
+ // التاني، فلازم نفك الصيغة السداسية كمان مش الشكل بالنقط بس.
160
+ function mappedIpv4(v6) {
161
+ const dotted = /^::(?:ffff:)?(\d+\.\d+\.\d+\.\d+)$/.exec(v6);
162
+ if (dotted) return dotted[1];
163
+
164
+ const hex = /^::(?:ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(v6);
165
+ if (!hex) return null;
166
+ const high = parseInt(hex[1], 16);
167
+ const low = parseInt(hex[2], 16);
168
+ return `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`;
169
+ }
170
+
171
+ function isPrivateIp(ip) {
172
+ const family = net.isIP(ip);
173
+
174
+ if (family === 4) {
175
+ const [a, b] = ip.split(".").map(Number);
176
+ if (a === 0 || a === 10 || a === 127) return true;
177
+ if (a === 169 && b === 254) return true; // link-local + metadata
178
+ if (a === 172 && b >= 16 && b <= 31) return true;
179
+ if (a === 192 && b === 168) return true;
180
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
181
+ if (a >= 224) return true; // multicast + reserved
182
+ return false;
183
+ }
184
+
185
+ if (family === 6) {
186
+ const v6 = ip.toLowerCase();
187
+ if (v6 === "::" || v6 === "::1") return true;
188
+ if (/^f[cd]/.test(v6)) return true; // unique local
189
+ if (v6.startsWith("fe80")) return true; // link local
190
+ const mapped = mappedIpv4(v6);
191
+ if (mapped) return isPrivateIp(mapped);
192
+ return false;
193
+ }
194
+
195
+ return true; // مش عنوان صالح أصلاً
196
+ }
197
+
198
+ // ملاحظة: فيه فرصة نظرية إن الـ DNS يرد بعنوان تاني بين الفحص والاتصال
199
+ // (DNS rebinding). قفلها بالكامل محتاج تثبيت الـ IP على مستوى الـ socket؛
200
+ // اللي هنا بيقفل الحالات العملية: أسماء داخلية، صيغ IP بديلة، والتحويلات.
201
+ async function assertPublicUrl(rawUrl) {
202
+ let url;
203
+ try {
204
+ url = new URL(rawUrl);
205
+ } catch {
206
+ throw new Error("الرابط مش صالح");
207
+ }
208
+ if (!/^https?:$/.test(url.protocol)) throw new Error("مسموح بـ http/https بس");
209
+
210
+ const host = url.hostname.replace(/^\[|\]$/g, "");
211
+
212
+ if (net.isIP(host)) {
213
+ if (isPrivateIp(host)) throw new Error("الرابط ده ممنوع");
214
+ return url;
215
+ }
216
+
217
+ let addresses;
218
+ try {
219
+ addresses = await dns.lookup(host, { all: true });
220
+ } catch {
221
+ throw new Error("مش قادر أوصل للدومين ده");
222
+ }
223
+ if (!addresses.length || addresses.some((a) => isPrivateIp(a.address))) {
224
+ throw new Error("الرابط ده ممنوع");
225
+ }
226
+
227
+ return url;
228
+ }
229
+
230
+ const MAX_REDIRECTS = 4;
231
+
232
+ async function fetchUrl(rawUrl) {
233
+ let url = await assertPublicUrl(rawUrl);
234
+ let response;
235
+
236
+ for (let hop = 0; ; hop++) {
237
+ response = await axios.get(url.toString(), {
238
+ responseType: "arraybuffer",
239
+ timeout: 20000,
240
+ // بنمشي ورا التحويلات بنفسنا عشان نفحص كل خطوة — axios بيفحص الأول بس.
241
+ maxRedirects: 0,
242
+ maxContentLength: MAX_FETCH_BYTES,
243
+ headers: {
244
+ "User-Agent": UA,
245
+ "Accept-Language": "ar,en;q=0.9",
246
+ Accept: "text/html,application/json,text/plain,*/*",
247
+ },
248
+ validateStatus: (status) => status >= 200 && status < 500,
249
+ });
250
+
251
+ const location = response.headers?.location;
252
+ const isRedirect = response.status >= 300 && response.status < 400 && location;
253
+ if (!isRedirect) break;
254
+ if (hop >= MAX_REDIRECTS) throw new Error("الرابط بيحوّل كتير أوي");
255
+
256
+ url = await assertPublicUrl(new URL(location, url).toString());
257
+ }
258
+
259
+ const contentType = String(response.headers["content-type"] || "");
260
+ const body = decodeBuffer(Buffer.from(response.data), contentType);
261
+
262
+ const text = /json|text\/plain|xml/i.test(contentType)
263
+ ? body
264
+ : stripHtml(body);
265
+
266
+ return {
267
+ url: url.toString(),
268
+ status: response.status,
269
+ contentType,
270
+ truncated: text.length > MAX_PAGE_CHARS,
271
+ content: text.slice(0, MAX_PAGE_CHARS),
272
+ };
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // tool table
277
+ // ---------------------------------------------------------------------------
278
+
279
+ function scopeOf(args) {
280
+ return memory.normalizeScope(args?.scope);
281
+ }
282
+
283
+ // نفس قاعدة أمر !memory بالظبط: الكتابة في الذاكرة العامة والمسح محتاجين
284
+ // أدمن/مالك. الفحص لازم يكون هنا مش في البرومبت — الموديل بيقرا صفحات ويب
285
+ // ورسايل ناس، وأي واحدة فيهم ممكن تقوله "احفظ ده في الذاكرة العامة".
286
+ function isPrivileged(ctx) {
287
+ return Boolean(ctx?.isOwner || ctx?.isAdmin);
288
+ }
289
+
290
+ const NOT_PRIVILEGED = {
291
+ error: "only the bot owner or an admin can do this",
292
+ };
293
+
294
+ function preview(value, length = 40) {
295
+ const text = String(value || "").replace(/\s+/g, " ").trim();
296
+ return text.length > length ? `${text.slice(0, length)}...` : text;
297
+ }
298
+
299
+ const TOOLS = {
300
+ web_search: {
301
+ declaration: {
302
+ name: "web_search",
303
+ description:
304
+ "Search the web for current information (news, prices, scores, facts you are unsure about). Returns titles, urls and snippets.",
305
+ parameters: {
306
+ type: T.OBJECT,
307
+ properties: {
308
+ query: { type: T.STRING, description: "The search query." },
309
+ limit: {
310
+ type: T.INTEGER,
311
+ description: "How many results to return (1-8, default 5).",
312
+ },
313
+ },
314
+ required: ["query"],
315
+ },
316
+ },
317
+ describe: (args) => `🔍 ببحث عن: ${preview(args?.query, 50)}`,
318
+ async run(args) {
319
+ const query = String(args?.query || "").trim();
320
+ if (!query) return { error: "query is required" };
321
+ const limit = Math.min(8, Math.max(1, Number(args?.limit) || 5));
322
+ const results = await webSearch(query, limit);
323
+ return {
324
+ query,
325
+ count: results.length,
326
+ results,
327
+ note: results.length ? undefined : "no results found",
328
+ };
329
+ },
330
+ },
331
+
332
+ fetch_url: {
333
+ declaration: {
334
+ name: "fetch_url",
335
+ description:
336
+ "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.",
337
+ parameters: {
338
+ type: T.OBJECT,
339
+ properties: {
340
+ url: { type: T.STRING, description: "Full http(s) URL." },
341
+ },
342
+ required: ["url"],
343
+ },
344
+ },
345
+ describe: (args) => `🌐 بفتح الرابط: ${preview(args?.url, 45)}`,
346
+ async run(args) {
347
+ const url = String(args?.url || "").trim();
348
+ if (!url) return { error: "url is required" };
349
+ return fetchUrl(url);
350
+ },
351
+ },
352
+
353
+ save_memory: {
354
+ declaration: {
355
+ name: "save_memory",
356
+ description:
357
+ "Save a fact to long-term memory so it survives across conversations. Use it whenever the user says things like 'save this', 'remember that', 'احفظ ده في ذاكرتك'. scope 'chat' keeps it to this conversation, scope 'global' shares it with every chat.",
358
+ parameters: {
359
+ type: T.OBJECT,
360
+ properties: {
361
+ content: {
362
+ type: T.STRING,
363
+ description:
364
+ "The fact to remember, written as a short standalone sentence.",
365
+ },
366
+ scope: {
367
+ type: T.STRING,
368
+ description: "'chat' (default) or 'global'.",
369
+ },
370
+ },
371
+ required: ["content"],
372
+ },
373
+ },
374
+ describe: (args) =>
375
+ `🧠 بحفظ في ${scopeOf(args) === "global" ? "الذاكرة العامة" : "ذاكرة الشات"}: ${preview(
376
+ args?.content
377
+ )}`,
378
+ async run(args, ctx) {
379
+ const scope = scopeOf(args);
380
+ // الذاكرة العامة بتتحقن في البرومبت بتاع كل الشاتات، فمش أي حد يكتب فيها.
381
+ if (scope === "global" && !isPrivileged(ctx)) {
382
+ return { ...NOT_PRIVILEGED, saved: false };
383
+ }
384
+ const entry = memory.addMemory({
385
+ scope,
386
+ chatId: ctx.chatId,
387
+ content: args?.content,
388
+ by: ctx.senderName || ctx.senderId,
389
+ chatName: ctx.chatName,
390
+ });
391
+ return {
392
+ saved: true,
393
+ id: entry.id,
394
+ scope,
395
+ file: scope === "global" ? "global.md" : `chats/${ctx.chatId}.md`,
396
+ };
397
+ },
398
+ },
399
+
400
+ search_memory: {
401
+ declaration: {
402
+ name: "search_memory",
403
+ description:
404
+ "Look up what is already saved in long-term memory. Everything is also injected into your system prompt, so use this only to double-check or to get entry ids before forgetting something.",
405
+ parameters: {
406
+ type: T.OBJECT,
407
+ properties: {
408
+ query: {
409
+ type: T.STRING,
410
+ description: "Optional text filter; omit to list everything.",
411
+ },
412
+ scope: {
413
+ type: T.STRING,
414
+ description: "'chat' (default), 'global', or 'all'.",
415
+ },
416
+ },
417
+ },
418
+ },
419
+ describe: () => "🧠 بدور في الذاكرة...",
420
+ async run(args, ctx) {
421
+ const wanted = String(args?.scope || "chat").toLowerCase();
422
+ const scopes = wanted === "all" ? ["chat", "global"] : [scopeOf(args)];
423
+ const out = {};
424
+ for (const scope of scopes) {
425
+ out[scope] = memory
426
+ .searchMemory(args?.query, { scope, chatId: ctx.chatId })
427
+ .map((entry) => ({ id: entry.id, content: entry.content, at: entry.at }));
428
+ }
429
+ return out;
430
+ },
431
+ },
432
+
433
+ forget_memory: {
434
+ declaration: {
435
+ name: "forget_memory",
436
+ description:
437
+ "Delete one saved memory entry, addressed by its id, its position (1-based), or a text fragment.",
438
+ parameters: {
439
+ type: T.OBJECT,
440
+ properties: {
441
+ ref: {
442
+ type: T.STRING,
443
+ description: "Entry id, index, or a fragment of its text.",
444
+ },
445
+ scope: { type: T.STRING, description: "'chat' (default) or 'global'." },
446
+ },
447
+ required: ["ref"],
448
+ },
449
+ },
450
+ describe: (args) => `🗑️ بمسح من الذاكرة: ${preview(args?.ref)}`,
451
+ async run(args, ctx) {
452
+ if (!isPrivileged(ctx)) return { ...NOT_PRIVILEGED, removed: false };
453
+ const scope = scopeOf(args);
454
+ const removed = memory.removeMemory({
455
+ scope,
456
+ chatId: ctx.chatId,
457
+ ref: args?.ref,
458
+ });
459
+ return removed
460
+ ? { removed: true, content: removed.content, scope }
461
+ : { removed: false, reason: "no matching entry" };
462
+ },
463
+ },
464
+
465
+ grant_role: {
466
+ declaration: {
467
+ name: "grant_role",
468
+ description:
469
+ "Give someone bot privileges. role 'admin' makes them an admin in every chat; role 'owner' gives full access. Only an owner may call this — say so politely if it is refused.",
470
+ parameters: {
471
+ type: T.OBJECT,
472
+ properties: {
473
+ target: {
474
+ type: T.STRING,
475
+ description:
476
+ "Phone number or JID of the person. Use the mentioned/quoted user's id when the request points at someone in the chat.",
477
+ },
478
+ role: { type: T.STRING, description: "'admin' or 'owner'." },
479
+ },
480
+ required: ["target"],
481
+ },
482
+ },
483
+ describe: (args) =>
484
+ `🔑 ببص على صلاحيات: ${preview(args?.target, 25)} (${args?.role || "admin"})`,
485
+ async run(args, ctx) {
486
+ if (!ctx.isOwner) {
487
+ return { error: "only the bot owner can grant roles", granted: false };
488
+ }
489
+ const role = String(args?.role || "admin").toLowerCase() === "owner"
490
+ ? "owner"
491
+ : "admin";
492
+ const record = await grantRole(args?.target, role);
493
+ logger.info(
494
+ { target: args?.target, role, by: ctx.senderId },
495
+ "[aiTools] role granted by the agent"
496
+ );
497
+ return {
498
+ granted: true,
499
+ role,
500
+ user: record ? record.jid : args?.target,
501
+ };
502
+ },
503
+ },
504
+
505
+ revoke_role: {
506
+ declaration: {
507
+ name: "revoke_role",
508
+ description: "Take back bot privileges from someone. Owner only.",
509
+ parameters: {
510
+ type: T.OBJECT,
511
+ properties: {
512
+ target: { type: T.STRING, description: "Phone number or JID." },
513
+ role: { type: T.STRING, description: "'admin' or 'owner'." },
514
+ },
515
+ required: ["target"],
516
+ },
517
+ },
518
+ describe: (args) => `🔑 بسحب صلاحية من: ${preview(args?.target, 25)}`,
519
+ async run(args, ctx) {
520
+ if (!ctx.isOwner) {
521
+ return { error: "only the bot owner can revoke roles", revoked: false };
522
+ }
523
+ const role = String(args?.role || "admin").toLowerCase() === "owner"
524
+ ? "owner"
525
+ : "admin";
526
+ const record = await revokeRole(args?.target, role);
527
+ return { revoked: true, role, user: record ? record.jid : args?.target };
528
+ },
529
+ },
530
+
531
+ list_roles: {
532
+ declaration: {
533
+ name: "list_roles",
534
+ description:
535
+ "List who currently holds bot owner / admin roles. Owners and admins only.",
536
+ parameters: { type: T.OBJECT, properties: {} },
537
+ },
538
+ describe: () => "🔑 بجيب قائمة الصلاحيات...",
539
+ async run(_args, ctx) {
540
+ if (!ctx.isOwner && !ctx.isAdmin) {
541
+ return { error: "only owners and admins can list roles" };
542
+ }
543
+ const { owners, admins } = await listRoles();
544
+ const shape = (user) => ({
545
+ id: user.jid,
546
+ phone: user.phone,
547
+ name: user.displayName,
548
+ });
549
+ return { owners: owners.map(shape), admins: admins.map(shape) };
550
+ },
551
+ },
552
+
553
+ get_datetime: {
554
+ declaration: {
555
+ name: "get_datetime",
556
+ description:
557
+ "Current date and time. Use it instead of guessing whenever the answer depends on 'now'.",
558
+ parameters: {
559
+ type: T.OBJECT,
560
+ properties: {
561
+ timezone: {
562
+ type: T.STRING,
563
+ description: `IANA timezone, default ${settings.get("bot_timezone")}.`,
564
+ },
565
+ },
566
+ },
567
+ },
568
+ describe: () => "🕒 بشوف الوقت...",
569
+ async run(args) {
570
+ const timeZone = args?.timezone || settings.get("bot_timezone");
571
+ const now = new Date();
572
+ let local;
573
+ try {
574
+ local = new Intl.DateTimeFormat("ar-EG", {
575
+ timeZone,
576
+ dateStyle: "full",
577
+ timeStyle: "short",
578
+ }).format(now);
579
+ } catch {
580
+ local = now.toString();
581
+ }
582
+ return { iso: now.toISOString(), timezone: timeZone, local };
583
+ },
584
+ },
585
+ };
586
+
587
+ /** Everything Gemini needs to know about the tools, in one `tools` entry. */
588
+ function toolDeclarations() {
589
+ return [
590
+ {
591
+ functionDeclarations: Object.values(TOOLS).map((tool) => tool.declaration),
592
+ },
593
+ ];
594
+ }
595
+
596
+ /** The status line for a tool call (falls back to the raw name). */
597
+ function describeCall(name, args) {
598
+ const tool = TOOLS[name];
599
+ if (!tool) return `⚙️ بشغّل: ${name}`;
600
+ try {
601
+ return tool.describe(args) || `⚙️ بشغّل: ${name}`;
602
+ } catch {
603
+ return `⚙️ بشغّل: ${name}`;
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Run one tool call. Never throws — a failure comes back as `{ error }` so the
609
+ * model can apologise or try something else instead of the turn dying.
610
+ */
611
+ async function runTool(name, args, ctx) {
612
+ const tool = TOOLS[name];
613
+ if (!tool) return { error: `unknown tool: ${name}` };
614
+
615
+ try {
616
+ const result = await tool.run(args || {}, ctx || {});
617
+ return result && typeof result === "object" ? result : { result };
618
+ } catch (err) {
619
+ logger.warn(
620
+ { err: err?.message, tool: name },
621
+ "[aiTools] tool call failed"
622
+ );
623
+ return { error: `${err?.name || "Error"}: ${err?.message || err}` };
624
+ }
625
+ }
626
+
627
+ module.exports = {
628
+ TOOLS,
629
+ toolDeclarations,
630
+ describeCall,
631
+ runTool,
632
+ webSearch,
633
+ fetchUrl,
634
+ decodeText,
635
+ };
@@ -0,0 +1,61 @@
1
+ // Timezone helpers.
2
+ //
3
+ // المستخدم بيكتب "22:30 07-06-2025" وهو قاصد توقيت البوت (BOT_TIMEZONE)، مش
4
+ // توقيت السيرفر. `new Date(y, m, d, h, mi)` بيبني بتوقيت السيرفر المحلي، فعلى
5
+ // سيرفر UTC الرسالة كانت بتتجدول بفارق ساعتين/تلاتة عن اللي المستخدم طلبه.
6
+
7
+ const settings = require("../config/settings.cjs");
8
+
9
+ // A getter, not a constant: the timezone is changeable from the dashboard.
10
+ const defaultTimezone = () => settings.get("bot_timezone");
11
+
12
+ // الفرق بين المنطقة الزمنية دي و UTC عند اللحظة دي بالتحديد (بيتغير مع
13
+ // التوقيت الصيفي، فلازم يتحسب على تاريخ معيّن مش مرة واحدة).
14
+ function zoneOffsetMs(date, timeZone = defaultTimezone()) {
15
+ const parts = new Intl.DateTimeFormat("en-US", {
16
+ timeZone,
17
+ hourCycle: "h23",
18
+ year: "numeric",
19
+ month: "2-digit",
20
+ day: "2-digit",
21
+ hour: "2-digit",
22
+ minute: "2-digit",
23
+ second: "2-digit",
24
+ })
25
+ .formatToParts(date)
26
+ .reduce((acc, part) => {
27
+ acc[part.type] = part.value;
28
+ return acc;
29
+ }, {});
30
+
31
+ const wallClock = Date.UTC(
32
+ Number(parts.year),
33
+ Number(parts.month) - 1,
34
+ Number(parts.day),
35
+ Number(parts.hour),
36
+ Number(parts.minute),
37
+ Number(parts.second)
38
+ );
39
+
40
+ // بنشيل الملي ثانية من الطرفين عشان الفرق يطلع نظيف.
41
+ return wallClock - Math.floor(date.getTime() / 1000) * 1000;
42
+ }
43
+
44
+ // ساعة حائط في منطقة زمنية -> لحظة حقيقية (Date بـ UTC جوّاها).
45
+ function zonedTimeToDate(
46
+ { year, month, day, hour = 0, minute = 0, second = 0 },
47
+ timeZone = defaultTimezone()
48
+ ) {
49
+ const asUtc = Date.UTC(year, month - 1, day, hour, minute, second);
50
+ if (!Number.isFinite(asUtc)) return new Date(NaN);
51
+
52
+ // تخمين ثم تصحيح: التكرار التاني بيمسك الحالات اللي الفرق فيها بيتغير
53
+ // بين التخمين والنتيجة (حوالين تحويل التوقيت الصيفي).
54
+ let timestamp = asUtc;
55
+ for (let i = 0; i < 2; i++) {
56
+ timestamp = asUtc - zoneOffsetMs(new Date(timestamp), timeZone);
57
+ }
58
+ return new Date(timestamp);
59
+ }
60
+
61
+ module.exports = { defaultTimezone, zoneOffsetMs, zonedTimeToDate };