levix-bot 4.0.4 → 4.1.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 (35) hide show
  1. package/package.json +1 -1
  2. package/public/dashboard/assets/index-DngAcp-o.js +431 -0
  3. package/public/dashboard/assets/index-LvfJ8ybs.css +2 -0
  4. package/public/dashboard/index.html +2 -2
  5. package/scheduler.cjs +19 -0
  6. package/src/commands/gemini.cjs +18 -21
  7. package/src/commands/group/antilink.cjs +3 -1
  8. package/src/commands/group/media.cjs +3 -1
  9. package/src/commands/help.cjs +12 -6
  10. package/src/commands/mod.cjs +3 -1
  11. package/src/commands/poll.cjs +36 -20
  12. package/src/commands/stt.cjs +1 -0
  13. package/src/commands/tts.cjs +36 -29
  14. package/src/config/baileys.config.js +5 -1
  15. package/src/config/settings.cjs +10 -2
  16. package/src/core/connection.js +21 -2
  17. package/src/core/socket.js +31 -7
  18. package/src/db/store.cjs +24 -0
  19. package/src/handlers/command.handler.js +5 -3
  20. package/src/middleware/antispam.middleware.js +4 -1
  21. package/src/middleware/blacklist.middleware.js +4 -1
  22. package/src/middleware/permissions.middleware.js +38 -51
  23. package/src/routes/dashboard.api.esm.js +91 -10
  24. package/src/services/aiAgent.cjs +7 -2
  25. package/src/services/aiRouter.cjs +9 -2
  26. package/src/services/aiToolAuth.cjs +114 -0
  27. package/src/services/aiTools.cjs +490 -55
  28. package/src/services/modelRegistry.cjs +20 -5
  29. package/src/utils/groupMetadataCache.cjs +83 -0
  30. package/src/utils/memory.cjs +35 -20
  31. package/src/utils/permissionLevel.cjs +55 -0
  32. package/src/utils/sendBotMessage.esm.js +14 -0
  33. package/src/utils/storage-hub.cjs +21 -0
  34. package/public/dashboard/assets/index-BnJbz8iW.css +0 -2
  35. package/public/dashboard/assets/index-Dn8ZyzsW.js +0 -426
@@ -42,6 +42,14 @@ const SETTINGS = [
42
42
  label: "Bot response language",
43
43
  hint: "Preferred language for AI replies and status updates. Can also be switched with !lang.",
44
44
  },
45
+ {
46
+ key: "link_previews_enabled",
47
+ type: "bool",
48
+ default: false,
49
+ group: "general",
50
+ label: "Link previews in messages",
51
+ hint: "Off (default) means the bot never fetches pages or images to preview a URL it sends — a real saving on mobile data, since AI answers with sources, short links and scheduled messages all contain URLs. On lets WhatsApp-style previews generate (one page fetch per URL sent).",
52
+ },
45
53
  {
46
54
  key: "bot_min_delay_ms",
47
55
  type: "int",
@@ -306,10 +314,10 @@ const SETTINGS = [
306
314
  {
307
315
  key: "thumbnail_remote",
308
316
  type: "bool",
309
- default: true,
317
+ default: false,
310
318
  group: "media",
311
319
  label: "Previews for remote media",
312
- hint: "Download media sent by URL to build a thumbnail before forwarding it.",
320
+ hint: "Off (default) keeps the bot from downloading media over the network just to build a thumbnail — send paths pass local files or buffers, so this rarely matters. On allows fetching a remote URL (up to the size limit below) when a send is given media by URL.",
313
321
  },
314
322
  {
315
323
  key: "thumbnail_remote_max_mb",
@@ -182,8 +182,24 @@ async function saveBotOwnerMetadata(sock) {
182
182
  }
183
183
  }
184
184
 
185
- // Cache all group metadata
186
- async function cacheAllGroups(sock) {
185
+ // Cache all group metadata — but not on every reconnect.
186
+ //
187
+ // Low-data design: `groupFetchAllParticipating()` pulls every group WITH its
188
+ // participants and description, and it used to run on every connection open —
189
+ // up to six times during one retry ladder. The full roster is only fetched
190
+ // when the last one is older than the TTL below (or on the first open after a
191
+ // process start, or explicitly forced); in between, pushed `groups.upsert` /
192
+ // `groups.update` events keep the cache current, and per-message reads fall
193
+ // back to single-group queries through resolveGroupMetadata() on a miss.
194
+ export const GROUP_SYNC_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
195
+ let lastFullGroupSyncAt = 0;
196
+
197
+ export async function cacheAllGroups(sock, { force = false, now = Date.now() } = {}) {
198
+ if (!force && lastFullGroupSyncAt && now - lastFullGroupSyncAt < GROUP_SYNC_TTL_MS) {
199
+ logger.info("[Cache] Recent full group sync is still fresh — skipping groupFetchAllParticipating");
200
+ return;
201
+ }
202
+
187
203
  logger.info("[Cache] Fetching and caching metadata for all groups...");
188
204
 
189
205
  try {
@@ -199,6 +215,9 @@ async function cacheAllGroups(sock) {
199
215
  cachedCount++;
200
216
  }
201
217
 
218
+ // Only a successful fetch counts as fresh — a failed one retries on the
219
+ // next connection open.
220
+ lastFullGroupSyncAt = now;
202
221
  logger.info(`[Cache] Successfully cached ${cachedCount} groups.`);
203
222
  } catch (err) {
204
223
  logger.error("[Error] Failed to fetch and cache groups:", err);
@@ -1,22 +1,45 @@
1
1
  import { makeWASocket } from "@whiskeysockets/baileys";
2
2
  import { createRequire } from "module";
3
- import NodeCache from "node-cache";
4
3
  import { useDatabaseAuthState } from "../auth/use-database-auth-state.js";
5
4
  import { getBaileysConfig, setRecentMessageGetter } from "../config/baileys.config.js";
6
- import { CACHE_CONFIG } from "../config/constants.js";
7
5
  import { getMessageFromRecent } from "../utils/recentMessageCache.esm.js";
8
6
 
9
7
  const require = createRequire(import.meta.url);
10
8
  const logger = require("../utils/logger.cjs");
11
9
  const { withMediaThumbnail } = require("../utils/thumbnail.cjs");
10
+ const settings = require("../config/settings.cjs");
12
11
 
13
- // Group metadata cache
14
- export const groupMetadataCache = new NodeCache(CACHE_CONFIG);
12
+ // The group-metadata cache lives in one shared CJS module so the CJS command
13
+ // files and these ESM core files see the SAME instance (see
14
+ // src/utils/groupMetadataCache.cjs). Re-exported under the historical name,
15
+ // so every existing import site keeps working.
16
+ const groupMetadataCacheModule = require("../utils/groupMetadataCache.cjs");
17
+ export const groupMetadataCache = groupMetadataCacheModule.cache;
15
18
 
16
19
  // Wire the recent-message cache into Baileys' getMessage hook so retries can
17
20
  // recover (rc10 enableAutoSessionRecreation needs this).
18
21
  setRecentMessageGetter(getMessageFromRecent);
19
22
 
23
+ /**
24
+ * Link previews are opt-in. Baileys fetches the page behind any URL in an
25
+ * outgoing text whenever `linkPreview` is merely undefined — with high-quality
26
+ * previews on it even uploads the page image. That made every AI answer with a
27
+ * Sources block, every short link and every scheduled message with a URL cost
28
+ * a silent page fetch. `linkPreview: null` skips it entirely; the operator
29
+ * opts back in with the `link_previews_enabled` setting (default off).
30
+ *
31
+ * This is the ONE choke point every outbound message passes through
32
+ * (sendBotMessage, the scheduler, status edits, legacy sock.sendMessage), so
33
+ * the guard lives here next to the thumbnail patch it shares a wrapper with.
34
+ */
35
+ function withoutUnwantedLinkPreview(content) {
36
+ if (!content || typeof content !== "object") return content;
37
+ if (typeof content.text !== "string") return content;
38
+ if ("linkPreview" in content) return content; // caller decided already
39
+ if (settings.get("link_previews_enabled")) return content; // operator opted in
40
+ return { ...content, linkPreview: null };
41
+ }
42
+
20
43
  /**
21
44
  * Baileys can't generate media previews in this install (no sharp/jimp, and its
22
45
  * video path shells out to a bare `ffmpeg` that isn't on the PATH), so every
@@ -25,10 +48,10 @@ setRecentMessageGetter(getMessageFromRecent);
25
48
  * sendBotMessage and the handful of legacy commands still calling
26
49
  * sock.sendMessage directly.
27
50
  */
28
- function withThumbnailSupport(sock) {
51
+ function withOutboundSavers(sock) {
29
52
  const original = sock.sendMessage.bind(sock);
30
53
 
31
- sock.sendMessage = async function sendMessageWithThumbnail(jid, content, options) {
54
+ sock.sendMessage = async function sendMessageWithOutboundSavers(jid, content, options) {
32
55
  let payload = content;
33
56
  try {
34
57
  payload = await withMediaThumbnail(content);
@@ -36,6 +59,7 @@ function withThumbnailSupport(sock) {
36
59
  logger.debug({ err: err?.message }, "[Socket] thumbnail step skipped");
37
60
  payload = content;
38
61
  }
62
+ payload = withoutUnwantedLinkPreview(payload);
39
63
  return original(jid, payload, options);
40
64
  };
41
65
 
@@ -83,7 +107,7 @@ export async function createWhatsAppSocket({ proxy = null, pairingCode = false }
83
107
  config.options = { ...(config.options || {}), dispatcher: proxy.dispatcher };
84
108
  }
85
109
 
86
- const sock = withThumbnailSupport(
110
+ const sock = withOutboundSavers(
87
111
  makeWASocket({
88
112
  auth: state,
89
113
  ...config,
package/src/db/store.cjs CHANGED
@@ -792,6 +792,28 @@ function deleteAllChatHistories() {
792
792
  q("DELETE FROM ai_history").run();
793
793
  }
794
794
 
795
+ // Read-only views for the dashboard's conversation inspector: which chats
796
+ // have stored history (no message content), and one chat's history with its
797
+ // last-updated stamp. `json_array_length` counts the stored turns without
798
+ // parsing the blob in JavaScript.
799
+ function listChatHistories() {
800
+ return q(
801
+ `SELECT chat_id AS chatId,
802
+ updated_at AS updatedAt,
803
+ json_array_length(history) AS turns
804
+ FROM ai_history
805
+ ORDER BY updated_at DESC`,
806
+ ).all();
807
+ }
808
+
809
+ function getChatHistoryWithMeta(chatId) {
810
+ const row = q("SELECT history, updated_at AS updatedAt FROM ai_history WHERE chat_id = ?").get(
811
+ chatId,
812
+ );
813
+ if (!row) return null;
814
+ return { history: parseJson(row.history, []), updatedAt: row.updatedAt };
815
+ }
816
+
795
817
  // ===================================================================
796
818
  // --- Baileys auth ---
797
819
  // ===================================================================
@@ -975,6 +997,8 @@ module.exports = {
975
997
  countSchedules,
976
998
  // AI history
977
999
  getChatHistory,
1000
+ getChatHistoryWithMeta,
1001
+ listChatHistories,
978
1002
  saveChatHistory,
979
1003
  deleteChatHistory,
980
1004
  deleteAllChatHistories,
@@ -4,6 +4,7 @@ import { dirname, join } from "path";
4
4
  import { fileURLToPath } from "url";
5
5
  import { checkBotAdmin, checkCommandPermission } from "../middleware/permissions.middleware.js";
6
6
  import { sendBotMessage } from "../utils/sendBotMessage.esm.js";
7
+ import { resolveGroupMetadata } from "../utils/groupMetadataCache.cjs";
7
8
 
8
9
  const __filename = fileURLToPath(import.meta.url);
9
10
  const __dirname = dirname(__filename);
@@ -289,9 +290,10 @@ export async function handleCommand(sock, msg, body) {
289
290
  }
290
291
 
291
292
  try {
292
- const groupMetadata = isGroup
293
- ? await sock.groupMetadata(msg.key.remoteJid).catch(() => null)
294
- : null;
293
+ // Cache-first: a live query only when no cached metadata exists yet (see
294
+ // utils/groupMetadataCache.cjs — this used to be one network round-trip
295
+ // per command attempt in every group).
296
+ const groupMetadata = isGroup ? await resolveGroupMetadata(sock, msg.key.remoteJid) : null;
295
297
 
296
298
  if (command.name !== "group") {
297
299
  // Enforce the `chat` constraint declared by the command. Until now this
@@ -7,6 +7,7 @@ import {
7
7
  sameUser,
8
8
  } from "../utils/permissions.esm.js";
9
9
  import { getGroupSettings, getPnForLid } from "../utils/storage.esm.js";
10
+ import { resolveGroupMetadata } from "../utils/groupMetadataCache.cjs";
10
11
 
11
12
  const require = createRequire(import.meta.url);
12
13
  const logger = require("../utils/logger.cjs");
@@ -41,7 +42,9 @@ export async function handleAntiSpam(sock, msg) {
41
42
  // Owners and admins are immune. The legacy owner mirror it used to import was
42
43
  // only ever populated with the bot's own JID, so real operators were
43
44
  // counted as spammers — the centralized check fixes that.
44
- const groupMetadata = await sock.groupMetadata(groupId);
45
+ // Cache-first: antispam runs on EVERY group message, so this read must not
46
+ // be a live query (see utils/groupMetadataCache.cjs).
47
+ const groupMetadata = await resolveGroupMetadata(sock, groupId);
45
48
  const protectedSender = senderCandidates.some(
46
49
  (candidate) =>
47
50
  isOwnerJid(candidate) ||
@@ -1,6 +1,7 @@
1
1
  import { createRequire } from "module";
2
2
  import { getSenderCandidates, isAdminInGroup, sameUser } from "../utils/permissions.esm.js";
3
3
  import { getGroupSettings } from "../utils/storage.esm.js";
4
+ import { resolveGroupMetadata } from "../utils/groupMetadataCache.cjs";
4
5
 
5
6
  const require = createRequire(import.meta.url);
6
7
  const logger = require("../utils/logger.cjs");
@@ -19,7 +20,9 @@ export async function checkBlacklist(sock, msg) {
19
20
  );
20
21
 
21
22
  if (listed) {
22
- const groupMetadata = await sock.groupMetadata(groupId);
23
+ // Cache-first: admin detection for a blacklisted sender, only when the
24
+ // blacklist actually matched (see utils/groupMetadataCache.cjs).
25
+ const groupMetadata = await resolveGroupMetadata(sock, groupId);
23
26
  const isSenderAdmin = senderCandidates.some((candidate) =>
24
27
  isAdminInGroup(groupMetadata, candidate),
25
28
  );
@@ -11,6 +11,7 @@ import {
11
11
 
12
12
  const require = createRequire(import.meta.url);
13
13
  const runtimeConfig = require("../config/runtime-config.cjs");
14
+ const { evaluatePermissionLevel } = require("../utils/permissionLevel.cjs");
14
15
  const logger = require("../utils/logger.cjs");
15
16
 
16
17
  /**
@@ -28,79 +29,65 @@ function resolvePermissionLevel(commandName) {
28
29
  }
29
30
 
30
31
  /**
31
- * Check if user has permission to execute a command.
32
+ * Who sent `msg`, as far as permissions are concerned. The same answer backs
33
+ * the command dispatcher and the AI agent's tools (services/aiToolAuth.cjs),
34
+ * so a person is never an admin for one door and a member for the other.
32
35
  *
33
- * @param {string} commandName - Canonical command name
34
- * @param {object} msg - Baileys message
36
+ * @param {object} msg - Baileys message
35
37
  * @param {object} groupMetadata - Group metadata (null for private chats)
36
38
  * @param {object} sock - Baileys socket
37
- * @returns {{hasPermission: boolean, reason: string, isOwner: boolean, isSenderAdmin: boolean}}
39
+ * @returns {{isGroup: boolean, senderId: string|null, candidates: string[],
40
+ * isOwner: boolean, isBotAdmin: boolean, isGroupAdmin: boolean,
41
+ * isSenderAdmin: boolean}}
38
42
  */
39
- export function checkCommandPermission(commandName, msg, groupMetadata, sock) {
40
- const isGroup = msg.key.remoteJid?.endsWith("@g.us");
43
+ export function resolveSender(msg, groupMetadata, sock) {
44
+ const isGroup = Boolean(msg?.key?.remoteJid?.endsWith("@g.us"));
41
45
  const senderId = getSenderId(msg, sock);
42
46
 
43
47
  // v7/LID: check every identifier the sender could appear under (LID + PN
44
48
  // alternates), so owner/admin detection isn't defeated by a LID<->PN mismatch.
45
49
  const senderCandidates = getSenderCandidates(msg, sock);
46
- const candidates = senderCandidates.length ? senderCandidates : [senderId];
50
+ const candidates = senderCandidates.length ? senderCandidates : [senderId].filter(Boolean);
47
51
 
48
52
  // fromMe is an immediate owner indicator (the bot is always its own owner).
49
- const isOwner = msg.key.fromMe || candidates.some((c) => isOwnerJid(c));
53
+ const isOwner = Boolean(msg?.key?.fromMe) || candidates.some((c) => isOwnerJid(c));
50
54
 
51
55
  // Bot-level admins (granted with `!perm add admin`, from the dashboard, or by
52
56
  // asking the AI) count as admins everywhere — including DMs, where there is
53
57
  // no group roster to consult.
54
58
  const isBotAdmin = !isOwner && candidates.some((c) => isBotAdminUser(c));
55
59
 
56
- const isSenderAdmin =
57
- isBotAdmin || (isGroup ? candidates.some((c) => isAdminInGroup(groupMetadata, c)) : false);
60
+ const isGroupAdmin = isGroup && candidates.some((c) => isAdminInGroup(groupMetadata, c));
61
+ const isSenderAdmin = isBotAdmin || isGroupAdmin;
58
62
 
59
- const permissionLevel = resolvePermissionLevel(commandName);
60
-
61
- let hasPermission = false;
62
- let reason = "";
63
-
64
- switch (permissionLevel) {
65
- case "MEMBERS":
66
- case "ALL":
67
- hasPermission = true;
68
- break;
69
-
70
- case "OWNER_ONLY":
71
- hasPermission = isOwner;
72
- reason = "🚫 هذا الأمر متاح للمالك فقط.";
73
- break;
74
-
75
- case "ADMINS_ONLY":
76
- if (!isGroup) {
77
- // Owners and bot-admins can run admin-only commands from anywhere —
78
- // useful for the operator pinging the bot privately to manage a group.
79
- hasPermission = isOwner || isBotAdmin;
80
- reason = "⚠️ هذا الأمر يعمل في المجموعات فقط (أو للمالك في الخاص).";
81
- } else {
82
- hasPermission = isSenderAdmin || isOwner;
83
- reason = "🚫 هذا الأمر متاح للمشرفين فقط.";
84
- }
85
- break;
63
+ return { isGroup, senderId, candidates, isOwner, isBotAdmin, isGroupAdmin, isSenderAdmin };
64
+ }
86
65
 
87
- case "ADMINS_OWNER":
88
- if (!isGroup) {
89
- hasPermission = isOwner || isBotAdmin;
90
- reason = "⚠️ هذا الأمر يعمل في المجموعات أو للمالك فقط.";
91
- } else {
92
- hasPermission = isOwner || isSenderAdmin;
93
- reason = "🚫 هذا الأمر متاح للمشرفين والمالك فقط.";
94
- }
95
- break;
66
+ /**
67
+ * Check if user has permission to execute a command.
68
+ *
69
+ * @param {string} commandName - Canonical command name
70
+ * @param {object} msg - Baileys message
71
+ * @param {object} groupMetadata - Group metadata (null for private chats)
72
+ * @param {object} sock - Baileys socket
73
+ * @returns {{hasPermission: boolean, reason: string, isOwner: boolean, isSenderAdmin: boolean}}
74
+ */
75
+ export function checkCommandPermission(commandName, msg, groupMetadata, sock) {
76
+ const sender = resolveSender(msg, groupMetadata, sock);
77
+ const permissionLevel = resolvePermissionLevel(commandName);
78
+ const { hasPermission, reason, unknownLevel } = evaluatePermissionLevel(permissionLevel, sender);
96
79
 
97
- default:
98
- logger.warn(`Unknown permission level: ${permissionLevel} for command: ${commandName}`);
99
- hasPermission = false;
100
- reason = "🚫 مستوى الصلاحية غير معروف.";
80
+ if (unknownLevel) {
81
+ logger.warn(`Unknown permission level: ${permissionLevel} for command: ${commandName}`);
101
82
  }
102
83
 
103
- return { hasPermission, reason, isOwner, isSenderAdmin, isBotAdmin };
84
+ return {
85
+ hasPermission,
86
+ reason,
87
+ isOwner: sender.isOwner,
88
+ isSenderAdmin: sender.isSenderAdmin,
89
+ isBotAdmin: sender.isBotAdmin,
90
+ };
104
91
  }
105
92
 
106
93
  /**
@@ -323,6 +323,8 @@ router.patch("/settings", (req, res) => {
323
323
  visionEnabled: key === "ai_vision_enabled" ? value : settings.get("ai_vision_enabled"),
324
324
  sttEnabled: key === "ai_stt_enabled" ? value : settings.get("ai_stt_enabled"),
325
325
  capabilities: resolved.capabilities,
326
+ capabilitySource: resolved.capabilitySource,
327
+ autoDetect: settings.get("ai_auto_detect_capabilities"),
326
328
  });
327
329
  if (key === "ai_vision_enabled") value = next.visionEnabled;
328
330
  if (key === "ai_stt_enabled") value = next.sttEnabled;
@@ -359,6 +361,8 @@ router.patch("/settings", (req, res) => {
359
361
  visionEnabled: settings.get("ai_vision_enabled"),
360
362
  sttEnabled: settings.get("ai_stt_enabled"),
361
363
  capabilities: resolved.capabilities,
364
+ capabilitySource: resolved.capabilitySource,
365
+ autoDetect: settings.get("ai_auto_detect_capabilities"),
362
366
  });
363
367
  if (Boolean(settings.get("ai_vision_enabled")) !== next.visionEnabled) {
364
368
  settings.set("ai_vision_enabled", next.visionEnabled);
@@ -512,16 +516,16 @@ router.get("/ai/memory", (req, res) => {
512
516
  try {
513
517
  const scopes = [];
514
518
 
515
- if (fs.existsSync(memory.GLOBAL_FILE)) {
516
- const stat = fs.statSync(memory.GLOBAL_FILE);
517
- scopes.push({
518
- scope: "global",
519
- label: "Global memory",
520
- entries: memory.listMemory({ scope: "global" }).length,
521
- bytes: stat.size,
522
- updatedAt: stat.mtimeMs,
523
- });
524
- }
519
+ // Global is always offered — even before the file exists — so the editor
520
+ // can create it with the first save.
521
+ const stat = fs.existsSync(memory.GLOBAL_FILE) ? fs.statSync(memory.GLOBAL_FILE) : null;
522
+ scopes.push({
523
+ scope: "global",
524
+ label: "Global memory",
525
+ entries: memory.listMemory({ scope: "global" }).length,
526
+ bytes: stat?.size ?? 0,
527
+ updatedAt: stat?.mtimeMs ?? null,
528
+ });
525
529
 
526
530
  if (fs.existsSync(memory.CHATS_DIR)) {
527
531
  for (const file of fs.readdirSync(memory.CHATS_DIR)) {
@@ -581,6 +585,83 @@ router.delete("/ai/memory/:scope", (req, res) => {
581
585
  }
582
586
  });
583
587
 
588
+ // ===========================================================================
589
+ // AI conversation inspector — read-only views of the stored `ai_history`
590
+ //
591
+ // The bot deliberately keeps only its own conversation windows, and the panel
592
+ // may look at them but never change them: deletes stay with `!del` / `!delall`
593
+ // in WhatsApp, so this endpoint is GET-only on purpose.
594
+ // ===========================================================================
595
+
596
+ const MAX_INSPECTOR_MESSAGES = 200;
597
+ const MAX_INSPECTOR_TEXT_CHARS = 2000;
598
+
599
+ router.get("/ai/conversations", (req, res) => {
600
+ getChatHistoryListAsync()
601
+ .then((conversations) => res.json({ success: true, conversations }))
602
+ .catch((error) => fail(res, error, "Error listing conversations"));
603
+ });
604
+
605
+ router.get("/ai/conversations/:chatId", (req, res) => {
606
+ getChatConversationAsync(req.params.chatId)
607
+ .then((conversation) => {
608
+ if (!conversation) return badRequest(res, "No stored conversation for that chat");
609
+ res.json({ success: true, ...conversation });
610
+ })
611
+ .catch((error) => fail(res, error, "Error reading conversation"));
612
+ });
613
+
614
+ async function getChatHistoryListAsync() {
615
+ const { listChatHistoriesAsync } = require("../utils/storage-hub.cjs");
616
+ const rows = await listChatHistoriesAsync();
617
+ return (rows || []).map((row) => ({
618
+ chatId: row.chatId,
619
+ turns: row.turns ?? 0,
620
+ updatedAt: row.updatedAt ?? null,
621
+ }));
622
+ }
623
+
624
+ // Flatten the canonical Gemini-parts history into display rows: text, media
625
+ // references (mime only — the bytes were never stored) and tool activity.
626
+ // Oldest first, capped to the most recent MAX_INSPECTOR_MESSAGES rows.
627
+ async function getChatConversationAsync(chatId) {
628
+ const { getChatHistoryWithMetaAsync } = require("../utils/storage-hub.cjs");
629
+ const meta = await getChatHistoryWithMetaAsync(chatId);
630
+ if (!meta) return null;
631
+
632
+ const messages = [];
633
+ for (const entry of Array.isArray(meta.history) ? meta.history : []) {
634
+ const role = entry?.role === "model" ? "model" : "user";
635
+ for (const part of Array.isArray(entry?.parts) ? entry.parts : []) {
636
+ if (messages.length >= MAX_INSPECTOR_MESSAGES) break;
637
+ if (typeof part?.text === "string" && part.text.trim()) {
638
+ messages.push({ role, kind: "text", text: part.text.slice(0, MAX_INSPECTOR_TEXT_CHARS) });
639
+ } else if (part?.fileData || part?.inlineData) {
640
+ messages.push({
641
+ role,
642
+ kind: "media",
643
+ mimeType: part?.fileData?.mimeType || part?.inlineData?.mimeType || null,
644
+ });
645
+ } else if (part?.functionCall) {
646
+ messages.push({
647
+ role: "model",
648
+ kind: "tool",
649
+ name: part.functionCall.name || "unknown",
650
+ });
651
+ } else if (part?.functionResponse) {
652
+ messages.push({
653
+ role: "user",
654
+ kind: "toolResult",
655
+ name: part.functionResponse.name || "unknown",
656
+ });
657
+ }
658
+ }
659
+ if (messages.length >= MAX_INSPECTOR_MESSAGES) break;
660
+ }
661
+
662
+ return { chatId, updatedAt: meta.updatedAt ?? null, truncated: messages.length >= MAX_INSPECTOR_MESSAGES, messages };
663
+ }
664
+
584
665
  // ===========================================================================
585
666
  // Groups
586
667
  // ===========================================================================
@@ -399,7 +399,9 @@ function formatSources(sources) {
399
399
  * @param {Array} options.parts - the Gemini parts for this message
400
400
  * @param {Array} [options.history]- prior conversation
401
401
  * @param {object} [options.status] - live status message (see utils/statusMessage.cjs)
402
- * @param {object} [options.context]- { chatId, senderId, senderName, isOwner, isAdmin, isGroup, chatName }
402
+ * @param {object} [options.context]- { caller, chatId, senderId, senderName, isOwner, isAdmin, isGroup, chatName }
403
+ * `caller` (aiToolAuth.resolveCaller) is the only thing the
404
+ * tools authorize against; the rest feeds the system prompt.
403
405
  * @param {boolean} [options.useTools=true]
404
406
  * @returns {Promise<{text: string, history: Array, toolCalls: Array, steps: number}>}
405
407
  */
@@ -499,6 +501,7 @@ async function runGeminiTurn({
499
501
  useTools = true,
500
502
  maxSteps = null,
501
503
  systemInstruction = null,
504
+ search = null,
502
505
  } = {}) {
503
506
  const genAI = geminiClient();
504
507
  if (!genAI) throw new Error("GEMINI_API_KEY غير معرف");
@@ -530,7 +533,9 @@ async function runGeminiTurn({
530
533
  });
531
534
  };
532
535
 
533
- let searchOffered = googleSearchEnabled();
536
+ // One-shot callers (the summarize tool) pass search:false explicitly; the
537
+ // chat agent follows the setting.
538
+ let searchOffered = search === null ? googleSearchEnabled() : Boolean(search);
534
539
  let chat = openChat(searchOffered);
535
540
  const toolCalls = [];
536
541
  // Only ever filled from real grounding metadata; see extractSources().
@@ -115,13 +115,20 @@ function detectModelCapabilities(provider = settings.get("ai_provider"), model =
115
115
  const resolved = resolveCapabilities(p, m);
116
116
  const caps = resolved.capabilities;
117
117
 
118
+ // "Unknown" is not "unsupported": for a model the registry has never seen
119
+ // (a gateway's own ids, a model newer than the catalog) the operator's
120
+ // toggles are the only gate, so report the capability as available and let
121
+ // ai_vision_enabled / ai_stt_enabled decide. Forcing it false here would
122
+ // silently drop every image/audio part on custom endpoints.
123
+ const known = resolved.capabilitySource === "seed" || resolved.capabilitySource === "provider";
124
+
118
125
  return {
119
126
  provider: p,
120
127
  model: m,
121
128
  capabilities: caps,
122
129
  capabilitySource: resolved.capabilitySource,
123
- supportsVision: Boolean(caps.vision),
124
- supportsAudioStt: Boolean(caps.stt),
130
+ supportsVision: known ? Boolean(caps.vision) : true,
131
+ supportsAudioStt: known ? Boolean(caps.stt) : true,
125
132
  visionRecommended: Boolean(caps.vision) && !settings.get("ai_vision_enabled"),
126
133
  };
127
134
  }