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.
- 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/services/aiTools.cjs
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
// The tools the AI agent can actually call.
|
|
2
2
|
//
|
|
3
|
-
// Each entry is { declaration, run(args, ctx), describe(args) }:
|
|
3
|
+
// Each entry is { declaration, access?, run(args, ctx), describe(args) }:
|
|
4
4
|
// declaration - what Gemini sees (name + JSON schema)
|
|
5
|
+
// access - who may call it (see aiToolAuth.cjs): the command it stands
|
|
6
|
+
// in for, plus any role that command checks internally. A
|
|
7
|
+
// function of the arguments when the action they pick changes
|
|
8
|
+
// the rule (global vs chat memory). Absent = open to anyone
|
|
9
|
+
// who may use the AI at all.
|
|
5
10
|
// run - what happens when the model calls it; ALWAYS resolves to a
|
|
6
11
|
// plain object (errors included) so one bad tool call doesn't
|
|
7
12
|
// kill the turn
|
|
8
13
|
// describe - the Arabic line shown in the live status message while the
|
|
9
14
|
// tool runs ("🔍 ببحث عن: ...")
|
|
10
15
|
//
|
|
11
|
-
// Anything privileged
|
|
12
|
-
//
|
|
13
|
-
//
|
|
16
|
+
// Anything privileged is gated on the *caller* — the person whose message
|
|
17
|
+
// started the run, resolved by aiToolAuth.resolveCaller() — and never on the
|
|
18
|
+
// model. runTool() checks `access` before run() is reached, and tools take
|
|
19
|
+
// every identity they act on (chat, sender, targets) from ctx.caller, never
|
|
20
|
+
// from their arguments.
|
|
14
21
|
|
|
15
22
|
const dns = require("node:dns").promises;
|
|
16
23
|
const https = require("node:https");
|
|
@@ -22,7 +29,9 @@ const logger = require("../utils/logger.cjs");
|
|
|
22
29
|
const memory = require("../utils/memory.cjs");
|
|
23
30
|
const { decodeBuffer, stripHtml, decodeText } = require("../utils/textDecode.cjs");
|
|
24
31
|
const { grantRole, revokeRole, listRoles } = require("../utils/permissions.cjs");
|
|
32
|
+
const { authorize } = require("./aiToolAuth.cjs");
|
|
25
33
|
const { isForbiddenIp } = require("../utils/providerUrl.cjs");
|
|
34
|
+
const { sendBotMessage } = require("../utils/sendBotMessage.cjs");
|
|
26
35
|
|
|
27
36
|
// @google/genai exports a `Type` enum (the old SDK called it `SchemaType`).
|
|
28
37
|
// Its members are the uppercase wire values — Type.STRING === "STRING" — so the
|
|
@@ -345,33 +354,30 @@ function scopeOf(args) {
|
|
|
345
354
|
return memory.normalizeScope(args?.scope);
|
|
346
355
|
}
|
|
347
356
|
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
function
|
|
352
|
-
return Boolean(ctx?.isOwner || ctx?.isAdmin);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
function isChatMemoryAdmin(ctx) {
|
|
356
|
-
return isBotPrivileged(ctx) || Boolean(ctx?.isGroupAdmin);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function roleTargetBound(target, ctx) {
|
|
357
|
+
// A role change may only target someone the sender themselves named: a
|
|
358
|
+
// mention, the author of the message they replied to, or a number they typed.
|
|
359
|
+
// A target the model came up with — from a web page, say — is refused.
|
|
360
|
+
function roleTargetBound(target, caller) {
|
|
360
361
|
const raw = String(target || "").trim();
|
|
361
362
|
if (!raw) return false;
|
|
362
|
-
const ids = [...(
|
|
363
|
+
const ids = [...(caller?.mentionedJids || []), caller?.quotedParticipant].filter(Boolean);
|
|
363
364
|
const { sameUserSync } = require("../utils/permissions.cjs");
|
|
364
365
|
if (ids.some((id) => sameUserSync(id, raw))) return true;
|
|
365
366
|
const digits = raw.replace(/\D/g, "");
|
|
366
|
-
if (
|
|
367
|
+
if (
|
|
368
|
+
digits.length >= 8 &&
|
|
369
|
+
String(caller?.text || "")
|
|
370
|
+
.replace(/\D/g, "")
|
|
371
|
+
.includes(digits)
|
|
372
|
+
) {
|
|
367
373
|
return true;
|
|
368
374
|
}
|
|
369
375
|
return false;
|
|
370
376
|
}
|
|
371
377
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
}
|
|
378
|
+
function roleOf(args) {
|
|
379
|
+
return String(args?.role || "admin").toLowerCase() === "owner" ? "owner" : "admin";
|
|
380
|
+
}
|
|
375
381
|
|
|
376
382
|
function preview(value, length = 40) {
|
|
377
383
|
const text = String(value || "")
|
|
@@ -535,24 +541,26 @@ const TOOLS = {
|
|
|
535
541
|
`🧠 Saving to ${scopeOf(args) === "global" ? "global" : "chat"} memory: ${preview(
|
|
536
542
|
args?.content,
|
|
537
543
|
)}`,
|
|
538
|
-
|
|
544
|
+
// Same rule as `!memory add`: anyone may add to this chat, but global
|
|
545
|
+
// memory reaches every chat's prompt, so only the owner or a bot admin.
|
|
546
|
+
access: (args) => ({
|
|
547
|
+
command: "memory",
|
|
548
|
+
role: scopeOf(args) === "global" ? "botPrivileged" : undefined,
|
|
549
|
+
}),
|
|
550
|
+
async run(args, { caller }) {
|
|
539
551
|
const scope = scopeOf(args);
|
|
540
|
-
// الذاكرة العامة بتتحقن في البرومبت بتاع كل الشاتات، فمش أي حد يكتب فيها.
|
|
541
|
-
if (scope === "global" && !isBotPrivileged(ctx)) {
|
|
542
|
-
return { ...NOT_PRIVILEGED, saved: false };
|
|
543
|
-
}
|
|
544
552
|
const entry = memory.addMemory({
|
|
545
553
|
scope,
|
|
546
|
-
chatId:
|
|
554
|
+
chatId: caller.chatId,
|
|
547
555
|
content: args?.content,
|
|
548
|
-
by:
|
|
549
|
-
chatName:
|
|
556
|
+
by: caller.senderName || caller.senderId,
|
|
557
|
+
chatName: caller.chatName,
|
|
550
558
|
});
|
|
551
559
|
return {
|
|
552
560
|
saved: true,
|
|
553
561
|
id: entry.id,
|
|
554
562
|
scope,
|
|
555
|
-
file: scope === "global" ? "global.md" : `chats/${
|
|
563
|
+
file: scope === "global" ? "global.md" : `chats/${caller.chatId}.md`,
|
|
556
564
|
};
|
|
557
565
|
},
|
|
558
566
|
},
|
|
@@ -578,13 +586,15 @@ const TOOLS = {
|
|
|
578
586
|
},
|
|
579
587
|
describe: () => "🧠 جاري البحث في الذاكرة...",
|
|
580
588
|
describeEn: () => "🧠 Searching memory...",
|
|
581
|
-
|
|
589
|
+
// `!memory search` reads both scopes for anyone who may run `!memory`.
|
|
590
|
+
access: { command: "memory" },
|
|
591
|
+
async run(args, { caller }) {
|
|
582
592
|
const wanted = String(args?.scope || "chat").toLowerCase();
|
|
583
593
|
const scopes = wanted === "all" ? ["chat", "global"] : [scopeOf(args)];
|
|
584
594
|
const out = {};
|
|
585
595
|
for (const scope of scopes) {
|
|
586
596
|
out[scope] = memory
|
|
587
|
-
.searchMemory(args?.query, { scope, chatId:
|
|
597
|
+
.searchMemory(args?.query, { scope, chatId: caller.chatId })
|
|
588
598
|
.map((entry) => ({ id: entry.id, content: entry.content, at: entry.at }));
|
|
589
599
|
}
|
|
590
600
|
return out;
|
|
@@ -610,13 +620,17 @@ const TOOLS = {
|
|
|
610
620
|
},
|
|
611
621
|
describe: (args) => `🗑️ جاري الحذف من الذاكرة: ${preview(args?.ref)}`,
|
|
612
622
|
describeEn: (args) => `🗑️ Removing from memory: ${preview(args?.ref)}`,
|
|
613
|
-
|
|
623
|
+
// `!memory forget`: global needs the owner or a bot admin; chat memory
|
|
624
|
+
// also lets an admin of this group tidy their own chat.
|
|
625
|
+
access: (args) => ({
|
|
626
|
+
command: "memory",
|
|
627
|
+
role: scopeOf(args) === "global" ? "botPrivileged" : "chatModerator",
|
|
628
|
+
}),
|
|
629
|
+
async run(args, { caller }) {
|
|
614
630
|
const scope = scopeOf(args);
|
|
615
|
-
const allowed = scope === "global" ? isBotPrivileged(ctx) : isChatMemoryAdmin(ctx);
|
|
616
|
-
if (!allowed) return { ...NOT_PRIVILEGED, removed: false };
|
|
617
631
|
const removed = memory.removeMemory({
|
|
618
632
|
scope,
|
|
619
|
-
chatId:
|
|
633
|
+
chatId: caller.chatId,
|
|
620
634
|
ref: args?.ref,
|
|
621
635
|
});
|
|
622
636
|
return removed
|
|
@@ -647,20 +661,20 @@ const TOOLS = {
|
|
|
647
661
|
`🔑 جاري فحص صلاحيات: ${preview(args?.target, 25)} (${args?.role || "admin"})`,
|
|
648
662
|
describeEn: (args) =>
|
|
649
663
|
`🔑 Checking permissions: ${preview(args?.target, 25)} (${args?.role || "admin"})`,
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
if (!roleTargetBound(args?.target,
|
|
664
|
+
// `!perm add`: that command's level (owner only unless the operator
|
|
665
|
+
// changed it), and the owner role is always the owner's to hand out.
|
|
666
|
+
access: (args) => ({ command: "perm", role: roleOf(args) === "owner" ? "owner" : undefined }),
|
|
667
|
+
async run(args, { caller }) {
|
|
668
|
+
if (!roleTargetBound(args?.target, caller)) {
|
|
655
669
|
return {
|
|
656
670
|
error: "mention, quote, or type the target phone number in your own request",
|
|
657
671
|
granted: false,
|
|
658
672
|
};
|
|
659
673
|
}
|
|
660
|
-
const role =
|
|
674
|
+
const role = roleOf(args);
|
|
661
675
|
const record = await grantRole(args?.target, role);
|
|
662
676
|
logger.info(
|
|
663
|
-
{ target: args?.target, role, by:
|
|
677
|
+
{ target: args?.target, role, by: caller.senderId },
|
|
664
678
|
"[aiTools] role granted by the agent",
|
|
665
679
|
);
|
|
666
680
|
return {
|
|
@@ -686,17 +700,16 @@ const TOOLS = {
|
|
|
686
700
|
},
|
|
687
701
|
describe: (args) => `🔑 جاري سحب الصلاحية من: ${preview(args?.target, 25)}`,
|
|
688
702
|
describeEn: (args) => `🔑 Revoking permissions from: ${preview(args?.target, 25)}`,
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
if (!roleTargetBound(args?.target, ctx)) {
|
|
703
|
+
// `!perm remove`, gated exactly like grant_role.
|
|
704
|
+
access: (args) => ({ command: "perm", role: roleOf(args) === "owner" ? "owner" : undefined }),
|
|
705
|
+
async run(args, { caller }) {
|
|
706
|
+
if (!roleTargetBound(args?.target, caller)) {
|
|
694
707
|
return {
|
|
695
708
|
error: "mention, quote, or type the target phone number in your own request",
|
|
696
709
|
revoked: false,
|
|
697
710
|
};
|
|
698
711
|
}
|
|
699
|
-
const role =
|
|
712
|
+
const role = roleOf(args);
|
|
700
713
|
const record = await revokeRole(args?.target, role);
|
|
701
714
|
return { revoked: true, role, user: record ? record.jid : args?.target };
|
|
702
715
|
},
|
|
@@ -705,15 +718,15 @@ const TOOLS = {
|
|
|
705
718
|
list_roles: {
|
|
706
719
|
declaration: {
|
|
707
720
|
name: "list_roles",
|
|
708
|
-
description:
|
|
721
|
+
description:
|
|
722
|
+
"List who currently holds bot owner / admin roles. Allowed for whoever may run !perm (the owner, by default).",
|
|
709
723
|
parameters: { type: T.OBJECT, properties: {} },
|
|
710
724
|
},
|
|
711
725
|
describe: () => "🔑 جاري جلب قائمة الصلاحيات...",
|
|
712
726
|
describeEn: () => "🔑 Fetching roles list...",
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
}
|
|
727
|
+
// `!perm list` — the roster is `!perm`'s to show.
|
|
728
|
+
access: { command: "perm" },
|
|
729
|
+
async run() {
|
|
717
730
|
const { owners, admins } = await listRoles();
|
|
718
731
|
const shape = (user) => ({
|
|
719
732
|
id: user.jid,
|
|
@@ -757,6 +770,317 @@ const TOOLS = {
|
|
|
757
770
|
return { iso: now.toISOString(), timezone: timeZone, local };
|
|
758
771
|
},
|
|
759
772
|
},
|
|
773
|
+
|
|
774
|
+
calculate: {
|
|
775
|
+
declaration: {
|
|
776
|
+
name: "calculate",
|
|
777
|
+
description:
|
|
778
|
+
"Evaluate a math expression EXACTLY — arithmetic, percentages ('15% من 300'), powers, factorials, sqrt/log/trig and min/max/avg/sum. Always use this for numbers instead of computing in your head.",
|
|
779
|
+
parameters: {
|
|
780
|
+
type: T.OBJECT,
|
|
781
|
+
properties: {
|
|
782
|
+
expression: {
|
|
783
|
+
type: T.STRING,
|
|
784
|
+
description: "The expression, e.g. '(12 + 8) * 3', '2^10', 'sqrt(144) + 5!', '25% من 480'.",
|
|
785
|
+
},
|
|
786
|
+
},
|
|
787
|
+
required: ["expression"],
|
|
788
|
+
},
|
|
789
|
+
},
|
|
790
|
+
describe: (args) => `🧮 جاري الحساب: ${preview(args?.expression, 40)}`,
|
|
791
|
+
describeEn: (args) => `🧮 Calculating: ${preview(args?.expression, 40)}`,
|
|
792
|
+
async run(args) {
|
|
793
|
+
const expression = String(args?.expression || "").trim();
|
|
794
|
+
if (!expression) return { error: "expression is required" };
|
|
795
|
+
// Same hand-written parser the !calc command uses — never eval.
|
|
796
|
+
const calc = require("../commands/calc.cjs");
|
|
797
|
+
try {
|
|
798
|
+
const { normalized, value } = calc._evaluate(expression);
|
|
799
|
+
return { expression: normalized, result: calc._format(value) };
|
|
800
|
+
} catch (err) {
|
|
801
|
+
return { error: err?.message || "could not parse the expression" };
|
|
802
|
+
}
|
|
803
|
+
},
|
|
804
|
+
},
|
|
805
|
+
|
|
806
|
+
create_reminder: {
|
|
807
|
+
declaration: {
|
|
808
|
+
name: "create_reminder",
|
|
809
|
+
description:
|
|
810
|
+
"Schedule a reminder message that the bot sends into THIS chat at a chosen time ('remind me in 20 minutes', 'ذكّرني بكرة الصبح'). Give EITHER in_minutes OR at_time, never both.",
|
|
811
|
+
parameters: {
|
|
812
|
+
type: T.OBJECT,
|
|
813
|
+
properties: {
|
|
814
|
+
message: {
|
|
815
|
+
type: T.STRING,
|
|
816
|
+
description: "What the reminder should say — short and standalone.",
|
|
817
|
+
},
|
|
818
|
+
in_minutes: {
|
|
819
|
+
type: T.NUMBER,
|
|
820
|
+
description: "Fire this many minutes from now (1-10080, i.e. up to 7 days).",
|
|
821
|
+
},
|
|
822
|
+
at_time: {
|
|
823
|
+
type: T.STRING,
|
|
824
|
+
description:
|
|
825
|
+
"Absolute time in the bot's timezone, format 'HH:mm DD-MM-YYYY' (e.g. '22:30 07-06-2026'). Use get_datetime first to convert 'tomorrow 9am' into this.",
|
|
826
|
+
},
|
|
827
|
+
},
|
|
828
|
+
required: ["message"],
|
|
829
|
+
},
|
|
830
|
+
},
|
|
831
|
+
describe: (args) => `⏰ جاري إعداد التذكير: ${preview(args?.message, 40)}`,
|
|
832
|
+
describeEn: (args) => `⏰ Setting a reminder: ${preview(args?.message, 40)}`,
|
|
833
|
+
// Posting into a chat at a later time is exactly what `!schedule` does, so
|
|
834
|
+
// it takes `!schedule`'s permission level — whatever the dashboard sets.
|
|
835
|
+
access: { command: "schedule" },
|
|
836
|
+
async run(args, { caller }) {
|
|
837
|
+
const message = String(args?.message || "").trim().slice(0, 500);
|
|
838
|
+
if (!message) return { error: "message is required" };
|
|
839
|
+
|
|
840
|
+
const { zonedTimeToDate } = require("../utils/datetime.cjs");
|
|
841
|
+
let when;
|
|
842
|
+
if (args?.at_time !== undefined && args?.at_time !== null && String(args.at_time).trim()) {
|
|
843
|
+
const match = /^(\d{1,2}):(\d{2})\s+(\d{1,2})-(\d{1,2})-(\d{4})$/.exec(
|
|
844
|
+
String(args.at_time).trim(),
|
|
845
|
+
);
|
|
846
|
+
if (!match) {
|
|
847
|
+
return { error: "at_time must be 'HH:mm DD-MM-YYYY' in the bot's timezone" };
|
|
848
|
+
}
|
|
849
|
+
const [, hour, minute, day, month, year] = match.map(Number);
|
|
850
|
+
when = zonedTimeToDate({ year, month, day, hour, minute });
|
|
851
|
+
} else {
|
|
852
|
+
const minutes = Number(args?.in_minutes);
|
|
853
|
+
if (!Number.isFinite(minutes) || minutes <= 0) {
|
|
854
|
+
return { error: "give either in_minutes (1-10080) or at_time ('HH:mm DD-MM-YYYY')" };
|
|
855
|
+
}
|
|
856
|
+
if (minutes > 7 * 24 * 60) return { error: "in_minutes can be at most 10080 (7 days)" };
|
|
857
|
+
when = new Date(Date.now() + minutes * 60_000);
|
|
858
|
+
}
|
|
859
|
+
if (Number.isNaN(when.getTime())) return { error: "that time is not a valid date" };
|
|
860
|
+
if (when.getTime() <= Date.now()) return { error: "that time is already in the past" };
|
|
861
|
+
|
|
862
|
+
// Same job shape the !schedule command stores, delivered by the same
|
|
863
|
+
// scheduler — one mechanism, two doors into it.
|
|
864
|
+
const scheduler = require("../../scheduler.cjs");
|
|
865
|
+
const job = {
|
|
866
|
+
id: `${Date.now()}${Math.floor(Math.random() * 90) + 10}`,
|
|
867
|
+
type: "once",
|
|
868
|
+
date: when.toISOString(),
|
|
869
|
+
message,
|
|
870
|
+
targetJid: caller.chatId,
|
|
871
|
+
creatorJid: caller.senderId || caller.chatId,
|
|
872
|
+
status: "pending",
|
|
873
|
+
};
|
|
874
|
+
const result = scheduler.scheduleJobNow(job);
|
|
875
|
+
if (!result.ok) {
|
|
876
|
+
return result.reason === "no_connection"
|
|
877
|
+
? { error: "WhatsApp is not connected right now, so the reminder cannot be armed" }
|
|
878
|
+
: { error: "the reminder time is invalid" };
|
|
879
|
+
}
|
|
880
|
+
let local;
|
|
881
|
+
try {
|
|
882
|
+
local = new Intl.DateTimeFormat("ar-EG", {
|
|
883
|
+
timeZone: settings.get("bot_timezone"),
|
|
884
|
+
dateStyle: "full",
|
|
885
|
+
timeStyle: "short",
|
|
886
|
+
}).format(when);
|
|
887
|
+
} catch {
|
|
888
|
+
local = when.toISOString();
|
|
889
|
+
}
|
|
890
|
+
return {
|
|
891
|
+
scheduled: true,
|
|
892
|
+
id: job.id,
|
|
893
|
+
willFireAt: when.toISOString(),
|
|
894
|
+
localTime: local,
|
|
895
|
+
delivery: `the bot posts it in this chat, prefixed with a "رسالة مجدولة" header`,
|
|
896
|
+
};
|
|
897
|
+
},
|
|
898
|
+
},
|
|
899
|
+
|
|
900
|
+
cancel_reminder: {
|
|
901
|
+
declaration: {
|
|
902
|
+
name: "cancel_reminder",
|
|
903
|
+
description:
|
|
904
|
+
"Cancel one pending reminder in THIS chat, by the id create_reminder returned. Allowed for whoever may run !deleteschedule here.",
|
|
905
|
+
parameters: {
|
|
906
|
+
type: T.OBJECT,
|
|
907
|
+
properties: {
|
|
908
|
+
id: { type: T.STRING, description: "The reminder id returned by create_reminder." },
|
|
909
|
+
},
|
|
910
|
+
required: ["id"],
|
|
911
|
+
},
|
|
912
|
+
},
|
|
913
|
+
describe: (args) => `⏰ جاري إلغاء التذكير: ${preview(args?.id, 20)}`,
|
|
914
|
+
describeEn: (args) => `⏰ Cancelling reminder: ${preview(args?.id, 20)}`,
|
|
915
|
+
// Deleting a scheduled job is `!deleteschedule`'s job, so its level.
|
|
916
|
+
access: { command: "deleteschedule" },
|
|
917
|
+
async run(args, { caller }) {
|
|
918
|
+
const id = String(args?.id || "").trim();
|
|
919
|
+
if (!id) return { error: "id is required" };
|
|
920
|
+
|
|
921
|
+
const scheduler = require("../../scheduler.cjs");
|
|
922
|
+
// Only jobs posting into the chat the request came from. An id is all
|
|
923
|
+
// it takes to address a job, and one can reach the model from a pasted
|
|
924
|
+
// message or a web page; `!deleteschedule` is the door for other chats.
|
|
925
|
+
const job = scheduler
|
|
926
|
+
.getScheduledJobs()
|
|
927
|
+
.find((entry) => String(entry.id) === id && entry.targetJid === caller.chatId);
|
|
928
|
+
if (!job) return { error: "no reminder with that id in this chat", cancelled: false };
|
|
929
|
+
|
|
930
|
+
scheduler.deleteScheduledJob(id);
|
|
931
|
+
return { cancelled: true, id, message: job.message };
|
|
932
|
+
},
|
|
933
|
+
},
|
|
934
|
+
|
|
935
|
+
speak: {
|
|
936
|
+
declaration: {
|
|
937
|
+
name: "speak",
|
|
938
|
+
description:
|
|
939
|
+
"Send your answer as a WhatsApp VOICE NOTE (text-to-speech) instead of text. Only when the user explicitly asks to hear it — 'قولها بصوت', 'read it aloud', 'send it as voice'. The text must be ready to be read aloud; keep it short.",
|
|
940
|
+
parameters: {
|
|
941
|
+
type: T.OBJECT,
|
|
942
|
+
properties: {
|
|
943
|
+
text: {
|
|
944
|
+
type: T.STRING,
|
|
945
|
+
description: "What the voice note says. Plain spoken prose, no markdown, no links.",
|
|
946
|
+
},
|
|
947
|
+
language: {
|
|
948
|
+
type: T.STRING,
|
|
949
|
+
description: "Optional language code, e.g. 'ar' (default) or 'en'.",
|
|
950
|
+
},
|
|
951
|
+
},
|
|
952
|
+
required: ["text"],
|
|
953
|
+
},
|
|
954
|
+
},
|
|
955
|
+
describe: (args) => `🎙️ جاري تحويل الرد لرسالة صوتية: ${preview(args?.text, 40)}`,
|
|
956
|
+
describeEn: (args) => `🎙️ Converting the reply to a voice note: ${preview(args?.text, 40)}`,
|
|
957
|
+
// Posting a voice note into this chat is what `!tts` does, so its level.
|
|
958
|
+
access: { command: "tts" },
|
|
959
|
+
async run(args, { caller, sock }) {
|
|
960
|
+
const text = String(args?.text || "").trim();
|
|
961
|
+
if (!text) return { error: "text is required" };
|
|
962
|
+
if (text.length > 3000) {
|
|
963
|
+
return { error: "text is too long for one voice note (3000 characters max)" };
|
|
964
|
+
}
|
|
965
|
+
if (!sock) return { error: "no WhatsApp connection is attached to this turn" };
|
|
966
|
+
|
|
967
|
+
const lang = String(args?.language || "ar").trim() || "ar";
|
|
968
|
+
const tts = require("../commands/tts.cjs");
|
|
969
|
+
const voice = await tts._synthesizeVoice(text, lang);
|
|
970
|
+
await sendBotMessage(
|
|
971
|
+
sock,
|
|
972
|
+
caller.chatId,
|
|
973
|
+
{ audio: voice.audio, mimetype: voice.mimetype, ptt: voice.ptt },
|
|
974
|
+
{ typing: false },
|
|
975
|
+
);
|
|
976
|
+
return {
|
|
977
|
+
sent: true,
|
|
978
|
+
format: voice.ptt ? "voice-note (ogg/opus)" : "audio (mp3 fallback)",
|
|
979
|
+
};
|
|
980
|
+
},
|
|
981
|
+
},
|
|
982
|
+
|
|
983
|
+
create_poll: {
|
|
984
|
+
declaration: {
|
|
985
|
+
name: "create_poll",
|
|
986
|
+
description:
|
|
987
|
+
"Create a native WhatsApp poll in THIS chat — members vote with a tap. Only when the user asks for a poll/vote ('اعمل استفتاء', 'create a poll').",
|
|
988
|
+
parameters: {
|
|
989
|
+
type: T.OBJECT,
|
|
990
|
+
properties: {
|
|
991
|
+
question: { type: T.STRING, description: "The poll question." },
|
|
992
|
+
options: {
|
|
993
|
+
type: T.ARRAY,
|
|
994
|
+
items: { type: T.STRING },
|
|
995
|
+
description: "2 to 12 distinct options.",
|
|
996
|
+
},
|
|
997
|
+
multi: {
|
|
998
|
+
type: T.BOOLEAN,
|
|
999
|
+
description: "true = members may pick several answers. Default false.",
|
|
1000
|
+
},
|
|
1001
|
+
},
|
|
1002
|
+
required: ["question", "options"],
|
|
1003
|
+
},
|
|
1004
|
+
},
|
|
1005
|
+
describe: (args) => `📊 جاري إنشاء الاستفتاء: ${preview(args?.question, 40)}`,
|
|
1006
|
+
describeEn: (args) => `📊 Creating a poll: ${preview(args?.question, 40)}`,
|
|
1007
|
+
// A poll is a group message like any other, so `!poll`'s level.
|
|
1008
|
+
access: { command: "poll" },
|
|
1009
|
+
async run(args, { caller, sock }) {
|
|
1010
|
+
if (!sock) return { error: "no WhatsApp connection is attached to this turn" };
|
|
1011
|
+
// Same builder the !poll command uses: dedupes, caps lengths, enforces
|
|
1012
|
+
// WhatsApp's 2-12 option ceiling, and throws the user-facing Arabic
|
|
1013
|
+
// message on any violation.
|
|
1014
|
+
const poll = require("../commands/poll.cjs");
|
|
1015
|
+
const content = poll._buildPollContent(args?.question, args?.options, {
|
|
1016
|
+
multi: Boolean(args?.multi),
|
|
1017
|
+
});
|
|
1018
|
+
await sendBotMessage(sock, caller.chatId, content, { typing: false });
|
|
1019
|
+
return {
|
|
1020
|
+
sent: true,
|
|
1021
|
+
question: content.poll.name,
|
|
1022
|
+
options: content.poll.values,
|
|
1023
|
+
multiAnswer: content.poll.selectableCount > 1,
|
|
1024
|
+
};
|
|
1025
|
+
},
|
|
1026
|
+
},
|
|
1027
|
+
|
|
1028
|
+
summarize_chat: {
|
|
1029
|
+
declaration: {
|
|
1030
|
+
name: "summarize_chat",
|
|
1031
|
+
description:
|
|
1032
|
+
"Summarize THIS chat's stored conversation window and save only the durable, useful facts to long-term memory (max 8 short facts). Only when the user asks for it — 'لخص محادثتنا واحفظ المهم', 'summarize our chat and remember the important stuff'.",
|
|
1033
|
+
parameters: {
|
|
1034
|
+
type: T.OBJECT,
|
|
1035
|
+
properties: {
|
|
1036
|
+
scope: {
|
|
1037
|
+
type: T.STRING,
|
|
1038
|
+
description:
|
|
1039
|
+
"'chat' (default) saves to this chat's memory only; 'global' shares with every chat and needs the owner or a bot admin.",
|
|
1040
|
+
},
|
|
1041
|
+
},
|
|
1042
|
+
},
|
|
1043
|
+
},
|
|
1044
|
+
describe: () => "🧠 جاري تلخيص المحادثة وحفظ المهم...",
|
|
1045
|
+
describeEn: () => "🧠 Summarizing the chat and saving what matters...",
|
|
1046
|
+
// Saving chat memory is what `!memory add` allows here; the global scope
|
|
1047
|
+
// reaches every chat's prompt, so it takes save_memory's owner/admin role.
|
|
1048
|
+
access: (args) => ({
|
|
1049
|
+
command: "memory",
|
|
1050
|
+
role: scopeOf(args) === "global" ? "botPrivileged" : undefined,
|
|
1051
|
+
}),
|
|
1052
|
+
async run(args, { caller }) {
|
|
1053
|
+
const scope = scopeOf(args);
|
|
1054
|
+
const { getChatHistoryAsync } = require("../utils/storage-hub.cjs");
|
|
1055
|
+
const history = await getChatHistoryAsync(caller.chatId);
|
|
1056
|
+
const transcript = transcriptFromHistory(history);
|
|
1057
|
+
if (!transcript) {
|
|
1058
|
+
return { error: "there is no stored conversation to summarize in this chat", saved: 0 };
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// One-shot model call over the transcript with no tools: the facts come
|
|
1062
|
+
// from what was actually said, never from tool arguments — the model
|
|
1063
|
+
// cannot inject content into memory through this tool, only ask for it.
|
|
1064
|
+
const summary = await summarizeWithProvider(transcript);
|
|
1065
|
+
const facts = factsFromSummary(summary);
|
|
1066
|
+
if (!facts.length) {
|
|
1067
|
+
return { saved: 0, note: "nothing durable enough to save came out of the summary" };
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const ids = [];
|
|
1071
|
+
for (const fact of facts) {
|
|
1072
|
+
const entry = memory.addMemory({
|
|
1073
|
+
scope,
|
|
1074
|
+
chatId: caller.chatId,
|
|
1075
|
+
content: fact,
|
|
1076
|
+
by: caller.senderName || caller.senderId,
|
|
1077
|
+
chatName: caller.chatName,
|
|
1078
|
+
});
|
|
1079
|
+
ids.push(entry.id);
|
|
1080
|
+
}
|
|
1081
|
+
return { saved: ids.length, scope, facts, ids };
|
|
1082
|
+
},
|
|
1083
|
+
},
|
|
760
1084
|
};
|
|
761
1085
|
|
|
762
1086
|
/** Everything Gemini needs to know about the tools, in one `tools` entry. */
|
|
@@ -768,6 +1092,80 @@ function toolDeclarations() {
|
|
|
768
1092
|
];
|
|
769
1093
|
}
|
|
770
1094
|
|
|
1095
|
+
// ---------------------------------------------------------------------------
|
|
1096
|
+
// summarize_chat internals
|
|
1097
|
+
// ---------------------------------------------------------------------------
|
|
1098
|
+
|
|
1099
|
+
const SUMMARIZER_PROMPT = `You compress a WhatsApp conversation into durable long-term facts.
|
|
1100
|
+
Return ONLY a plain Markdown bullet list, one fact per line, at most 8 bullets.
|
|
1101
|
+
A fact is stable and useful later: names, relationships, preferences, recurring plans, standing decisions, important context about the people in this chat.
|
|
1102
|
+
Do NOT include small talk, one-off questions, transient events, passwords or other secrets, or anything about roles, permissions or who may do what.
|
|
1103
|
+
Each bullet is one short standalone sentence, in the language the conversation mostly used.
|
|
1104
|
+
If nothing qualifies, return an empty response.`;
|
|
1105
|
+
|
|
1106
|
+
const SUMMARIZE_MAX_TRANSCRIPT_CHARS = 20000;
|
|
1107
|
+
const SUMMARIZE_MAX_FACTS = 8;
|
|
1108
|
+
const SUMMARIZE_MAX_FACT_CHARS = 200;
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Flatten stored agent history into a clean transcript: text parts only, one
|
|
1112
|
+
* line per part, roles named. Media, tool calls and thought parts are skipped.
|
|
1113
|
+
* When over the cap the OLDEST lines are dropped — recent context matters most.
|
|
1114
|
+
* @returns {string} "" when there is nothing readable.
|
|
1115
|
+
*/
|
|
1116
|
+
function transcriptFromHistory(history, maxChars = SUMMARIZE_MAX_TRANSCRIPT_CHARS) {
|
|
1117
|
+
const lines = [];
|
|
1118
|
+
for (const entry of Array.isArray(history) ? history : []) {
|
|
1119
|
+
const role = entry?.role === "model" ? "Assistant" : "User";
|
|
1120
|
+
for (const part of Array.isArray(entry?.parts) ? entry.parts : []) {
|
|
1121
|
+
const text = typeof part?.text === "string" ? part.text.trim() : "";
|
|
1122
|
+
if (text) lines.push(`${role}: ${text.replace(/\s+/g, " ").slice(0, 500)}`);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
if (!lines.length) return "";
|
|
1126
|
+
let out = lines.join("\n");
|
|
1127
|
+
if (out.length > maxChars) {
|
|
1128
|
+
out = out.slice(-maxChars);
|
|
1129
|
+
const firstNewline = out.indexOf("\n");
|
|
1130
|
+
if (firstNewline !== -1) out = out.slice(firstNewline + 1);
|
|
1131
|
+
}
|
|
1132
|
+
return out;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Parse the summarizer's answer into memory entries: bullet markers and
|
|
1137
|
+
* numbering stripped, empties dropped, hard-capped so one summary can't flood
|
|
1138
|
+
* a memory file.
|
|
1139
|
+
*/
|
|
1140
|
+
function factsFromSummary(text, { max = SUMMARIZE_MAX_FACTS, maxChars = SUMMARIZE_MAX_FACT_CHARS } = {}) {
|
|
1141
|
+
const out = [];
|
|
1142
|
+
for (const rawLine of String(text || "").split("\n")) {
|
|
1143
|
+
const line = rawLine.replace(/^\s*[-*•\d.)]+\s*/, "").trim();
|
|
1144
|
+
if (!line) continue;
|
|
1145
|
+
out.push(line.slice(0, maxChars));
|
|
1146
|
+
if (out.length >= max) break;
|
|
1147
|
+
}
|
|
1148
|
+
return out;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* One-shot summarization through whichever provider is active. No tools, no
|
|
1153
|
+
* search, no persona: a plain completion over the transcript so a summary
|
|
1154
|
+
* can't recurse into the agent loop or spend the caller's tool budget.
|
|
1155
|
+
*/
|
|
1156
|
+
async function summarizeWithProvider(transcript) {
|
|
1157
|
+
const { getProvider } = require("./aiRouter.cjs");
|
|
1158
|
+
const result = await getProvider().runTurn({
|
|
1159
|
+
parts: [{ text: transcript }],
|
|
1160
|
+
history: [],
|
|
1161
|
+
useTools: false,
|
|
1162
|
+
search: false,
|
|
1163
|
+
maxSteps: 1,
|
|
1164
|
+
systemInstruction: SUMMARIZER_PROMPT,
|
|
1165
|
+
});
|
|
1166
|
+
return result?.text || "";
|
|
1167
|
+
}
|
|
1168
|
+
|
|
771
1169
|
/** The status line for a tool call (falls back to the raw name). */
|
|
772
1170
|
function describeCall(name, args, lang = settings.get("bot_language")) {
|
|
773
1171
|
const tool = TOOLS[name];
|
|
@@ -784,16 +1182,49 @@ function describeCall(name, args, lang = settings.get("bot_language")) {
|
|
|
784
1182
|
}
|
|
785
1183
|
}
|
|
786
1184
|
|
|
1185
|
+
/**
|
|
1186
|
+
* The model's arguments, cut down to the properties the tool declares. The
|
|
1187
|
+
* model writes these after reading strangers' messages and web pages, so
|
|
1188
|
+
* nothing undeclared (a `senderId`, an `isOwner`) may ride along into run().
|
|
1189
|
+
* Frozen, so access() and run() are guaranteed to see the same values.
|
|
1190
|
+
*/
|
|
1191
|
+
function declaredArgs(tool, args) {
|
|
1192
|
+
const declared = tool.declaration?.parameters?.properties || {};
|
|
1193
|
+
const out = {};
|
|
1194
|
+
if (args && typeof args === "object") {
|
|
1195
|
+
for (const key of Object.keys(declared)) {
|
|
1196
|
+
if (Object.hasOwn(args, key)) out[key] = args[key];
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
return Object.freeze(out);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
787
1202
|
/**
|
|
788
1203
|
* Run one tool call. Never throws — a failure comes back as `{ error }` so the
|
|
789
1204
|
* model can apologise or try something else instead of the turn dying.
|
|
1205
|
+
*
|
|
1206
|
+
* `ctx.caller` must come from aiToolAuth.resolveCaller(); a tool with `access`
|
|
1207
|
+
* refuses anything else before run() is reached.
|
|
790
1208
|
*/
|
|
791
1209
|
async function runTool(name, args, ctx) {
|
|
792
1210
|
const tool = TOOLS[name];
|
|
793
1211
|
if (!tool) return { error: `unknown tool: ${name}` };
|
|
794
1212
|
|
|
1213
|
+
const input = declaredArgs(tool, args);
|
|
1214
|
+
const caller = ctx?.caller;
|
|
1215
|
+
|
|
795
1216
|
try {
|
|
796
|
-
const
|
|
1217
|
+
const access = typeof tool.access === "function" ? tool.access(input) : tool.access;
|
|
1218
|
+
const verdict = authorize(access || null, caller);
|
|
1219
|
+
if (!verdict.allowed) {
|
|
1220
|
+
logger.info(
|
|
1221
|
+
{ tool: name, chatId: caller?.chatId, senderId: caller?.senderId },
|
|
1222
|
+
"[aiTools] tool call refused",
|
|
1223
|
+
);
|
|
1224
|
+
return { error: verdict.error, denied: true };
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
const result = await tool.run(input, { caller, sock: ctx?.sock });
|
|
797
1228
|
return result && typeof result === "object" ? result : { result };
|
|
798
1229
|
} catch (err) {
|
|
799
1230
|
logger.warn({ err: err?.message, tool: name }, "[aiTools] tool call failed");
|
|
@@ -806,6 +1237,10 @@ module.exports = {
|
|
|
806
1237
|
toolDeclarations,
|
|
807
1238
|
describeCall,
|
|
808
1239
|
runTool,
|
|
1240
|
+
transcriptFromHistory,
|
|
1241
|
+
factsFromSummary,
|
|
1242
|
+
summarizeWithProvider,
|
|
1243
|
+
SUMMARIZER_PROMPT,
|
|
809
1244
|
webSearch,
|
|
810
1245
|
searchNews,
|
|
811
1246
|
searchWikipedia,
|