levix-bot 4.0.3 → 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.
- package/package.json +1 -1
- package/public/dashboard/assets/index-DngAcp-o.js +431 -0
- package/public/dashboard/assets/index-LvfJ8ybs.css +2 -0
- package/public/dashboard/index.html +2 -2
- package/scheduler.cjs +19 -0
- package/src/commands/gemini.cjs +18 -21
- package/src/commands/group/antilink.cjs +3 -1
- package/src/commands/group/media.cjs +3 -1
- package/src/commands/help.cjs +12 -6
- package/src/commands/mod.cjs +3 -1
- package/src/commands/poll.cjs +36 -20
- package/src/commands/stt.cjs +1 -0
- package/src/commands/tts.cjs +36 -29
- package/src/config/baileys.config.js +5 -1
- package/src/config/settings.cjs +10 -2
- package/src/core/connection.js +21 -2
- package/src/core/socket.js +31 -7
- package/src/db/store.cjs +24 -0
- package/src/handlers/command.handler.js +5 -3
- package/src/middleware/antispam.middleware.js +4 -1
- package/src/middleware/blacklist.middleware.js +4 -1
- package/src/middleware/permissions.middleware.js +38 -51
- package/src/routes/dashboard.api.esm.js +91 -10
- package/src/services/aiAgent.cjs +7 -2
- package/src/services/aiRouter.cjs +9 -2
- package/src/services/aiToolAuth.cjs +114 -0
- package/src/services/aiTools.cjs +490 -55
- package/src/services/modelRegistry.cjs +20 -5
- package/src/utils/groupMetadataCache.cjs +83 -0
- package/src/utils/memory.cjs +35 -20
- package/src/utils/permissionLevel.cjs +55 -0
- package/src/utils/sendBotMessage.esm.js +14 -0
- package/src/utils/storage-hub.cjs +21 -0
- package/public/dashboard/assets/index-BnJbz8iW.css +0 -2
- package/public/dashboard/assets/index-Dn8ZyzsW.js +0 -426
package/src/config/settings.cjs
CHANGED
|
@@ -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:
|
|
317
|
+
default: false,
|
|
310
318
|
group: "media",
|
|
311
319
|
label: "Previews for remote media",
|
|
312
|
-
hint: "
|
|
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",
|
package/src/core/connection.js
CHANGED
|
@@ -182,8 +182,24 @@ async function saveBotOwnerMetadata(sock) {
|
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
// Cache all group metadata
|
|
186
|
-
|
|
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);
|
package/src/core/socket.js
CHANGED
|
@@ -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
|
-
//
|
|
14
|
-
|
|
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
|
|
51
|
+
function withOutboundSavers(sock) {
|
|
29
52
|
const original = sock.sendMessage.bind(sock);
|
|
30
53
|
|
|
31
|
-
sock.sendMessage = async function
|
|
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 =
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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 {
|
|
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 {{
|
|
39
|
+
* @returns {{isGroup: boolean, senderId: string|null, candidates: string[],
|
|
40
|
+
* isOwner: boolean, isBotAdmin: boolean, isGroupAdmin: boolean,
|
|
41
|
+
* isSenderAdmin: boolean}}
|
|
38
42
|
*/
|
|
39
|
-
export function
|
|
40
|
-
const isGroup = msg
|
|
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
|
|
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
|
|
57
|
-
|
|
60
|
+
const isGroupAdmin = isGroup && candidates.some((c) => isAdminInGroup(groupMetadata, c));
|
|
61
|
+
const isSenderAdmin = isBotAdmin || isGroupAdmin;
|
|
58
62
|
|
|
59
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
hasPermission = false;
|
|
100
|
-
reason = "🚫 مستوى الصلاحية غير معروف.";
|
|
80
|
+
if (unknownLevel) {
|
|
81
|
+
logger.warn(`Unknown permission level: ${permissionLevel} for command: ${commandName}`);
|
|
101
82
|
}
|
|
102
83
|
|
|
103
|
-
return {
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
// ===========================================================================
|
package/src/services/aiAgent.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|