levix-bot 2.1.1 → 2.2.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/README.md +3 -1
- package/app.cjs +13 -5
- package/package.json +1 -1
- package/public/dashboard.css +2167 -519
- package/public/dashboard.js +100 -36
- package/scheduler.cjs +84 -17
- package/src/bootstrap/core.js +17 -11
- package/src/bootstrap/session-startup-policy.js +23 -0
- package/src/commands/autoschedule.cjs +19 -42
- package/src/config/ai-identity.cjs +4 -3
- package/src/config/brand.cjs +3 -0
- package/src/db/db.cjs +8 -0
- package/src/db/store.cjs +25 -5
- package/src/routes/dashboard.api.esm.js +60 -3
- package/src/utils/recurrence.cjs +161 -0
- package/src/utils/storage.cjs +1 -0
- package/src/utils/storage.esm.js +1 -0
- package/views/dashboard.ejs +464 -94
- package/views/login.ejs +50 -86
- package/views/qr.ejs +51 -53
- package/views/setup.ejs +67 -110
package/public/dashboard.js
CHANGED
|
@@ -89,11 +89,14 @@
|
|
|
89
89
|
function applyTheme(theme) {
|
|
90
90
|
document.documentElement.setAttribute("data-theme", theme);
|
|
91
91
|
const button = $("#theme-btn");
|
|
92
|
-
button.textContent = theme === "light" ? "☾" : "☀";
|
|
93
92
|
button.setAttribute(
|
|
94
93
|
"aria-label",
|
|
95
94
|
theme === "light" ? "Switch to dark theme" : "Switch to light theme"
|
|
96
95
|
);
|
|
96
|
+
button.setAttribute(
|
|
97
|
+
"title",
|
|
98
|
+
theme === "light" ? "Switch to dark theme" : "Switch to light theme"
|
|
99
|
+
);
|
|
97
100
|
try {
|
|
98
101
|
localStorage.setItem(THEME_KEY, theme);
|
|
99
102
|
} catch {
|
|
@@ -140,9 +143,12 @@
|
|
|
140
143
|
$$(".view").forEach((view) =>
|
|
141
144
|
view.classList.toggle("active", view.id === `view-${target}`)
|
|
142
145
|
);
|
|
143
|
-
$$(".nav a").forEach((link) =>
|
|
144
|
-
|
|
145
|
-
|
|
146
|
+
$$(".nav a").forEach((link) => {
|
|
147
|
+
const active = link.dataset.view === target;
|
|
148
|
+
link.classList.toggle("active", active);
|
|
149
|
+
if (active) link.setAttribute("aria-current", "page");
|
|
150
|
+
else link.removeAttribute("aria-current");
|
|
151
|
+
});
|
|
146
152
|
$("#page-title").textContent = VIEWS[target].title;
|
|
147
153
|
setSidebar(false);
|
|
148
154
|
|
|
@@ -227,20 +233,24 @@
|
|
|
227
233
|
setStatus(stats.connection);
|
|
228
234
|
|
|
229
235
|
const cards = [
|
|
230
|
-
["Groups", stats.totalGroups],
|
|
231
|
-
["Commands", stats.commandCount],
|
|
232
|
-
["Open debts", stats.totalDebts],
|
|
233
|
-
["Warnings", stats.totalWarnings],
|
|
234
|
-
["Notes", stats.totalNotes],
|
|
235
|
-
["Todo lists", stats.totalTodos],
|
|
236
|
+
["Groups", stats.totalGroups, "Communities", '<svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg>'],
|
|
237
|
+
["Commands", stats.commandCount, "Available", '<svg viewBox="0 0 24 24"><path d="m5 7 5 5-5 5M12 17h7"/></svg>'],
|
|
238
|
+
["Open debts", stats.totalDebts, "Unsettled", '<svg viewBox="0 0 24 24"><path d="M6 3h12v18l-3-2-3 2-3-2-3 2zM9 8h6M9 12h6"/></svg>'],
|
|
239
|
+
["Warnings", stats.totalWarnings, "Issued", '<svg viewBox="0 0 24 24"><path d="M12 4 3 20h18zM12 9v5M12 17h.01"/></svg>'],
|
|
240
|
+
["Notes", stats.totalNotes, "Saved", '<svg viewBox="0 0 24 24"><path d="M5 3h14v18H5zM8 8h8M8 12h8M8 16h5"/></svg>'],
|
|
241
|
+
["Todo lists", stats.totalTodos, "Tracked", '<svg viewBox="0 0 24 24"><path d="m5 7 2 2 4-4M13 7h6M5 14l2 2 4-4M13 14h6"/></svg>'],
|
|
236
242
|
];
|
|
237
243
|
|
|
238
244
|
$("#stat-cards").innerHTML = cards
|
|
239
245
|
.map(
|
|
240
|
-
([label, value]) => `
|
|
246
|
+
([label, value, caption, icon]) => `
|
|
241
247
|
<div class="stat">
|
|
242
|
-
<div class="
|
|
248
|
+
<div class="stat-top">
|
|
249
|
+
<span class="stat-icon" aria-hidden="true">${icon}</span>
|
|
250
|
+
<span class="label">${esc(label)}</span>
|
|
251
|
+
</div>
|
|
243
252
|
<div class="value">${esc(value ?? 0)}</div>
|
|
253
|
+
<div class="stat-foot"><span>${esc(caption)}</span><i aria-hidden="true"></i></div>
|
|
244
254
|
</div>`
|
|
245
255
|
)
|
|
246
256
|
.join("");
|
|
@@ -258,9 +268,9 @@
|
|
|
258
268
|
$("#overview-info").innerHTML = rows
|
|
259
269
|
.map(
|
|
260
270
|
([label, value]) => `
|
|
261
|
-
<div>
|
|
262
|
-
<div class="label"
|
|
263
|
-
<div
|
|
271
|
+
<div class="runtime-item">
|
|
272
|
+
<div class="runtime-label">${esc(label)}</div>
|
|
273
|
+
<div class="runtime-value">${esc(value)}</div>
|
|
264
274
|
</div>`
|
|
265
275
|
)
|
|
266
276
|
.join("");
|
|
@@ -597,7 +607,7 @@
|
|
|
597
607
|
? scopes
|
|
598
608
|
.map(
|
|
599
609
|
(entry) => `
|
|
600
|
-
<button class="btn btn-sm" data-scope="${esc(entry.scope)}">
|
|
610
|
+
<button class="btn btn-sm" type="button" data-scope="${esc(entry.scope)}">
|
|
601
611
|
${entry.scope === "global" ? "🌍" : "💬"} ${esc(entry.label)}
|
|
602
612
|
<span class="hint">${esc(entry.entries)} entries</span>
|
|
603
613
|
</button>`
|
|
@@ -795,7 +805,7 @@
|
|
|
795
805
|
</div>
|
|
796
806
|
|
|
797
807
|
<div style="grid-column:1/-1">
|
|
798
|
-
<button class="btn btn-primary" data-save-group="${id}">Save this group</button>
|
|
808
|
+
<button class="btn btn-primary" type="button" data-save-group="${id}">Save this group</button>
|
|
799
809
|
<span class="hint" style="margin-left:8px">Blacklisted: ${group.blacklist.length}</span>
|
|
800
810
|
</div>
|
|
801
811
|
</div>
|
|
@@ -872,6 +882,42 @@
|
|
|
872
882
|
|
|
873
883
|
// --- data tables --------------------------------------------------------
|
|
874
884
|
|
|
885
|
+
function scheduleState(job) {
|
|
886
|
+
const lifecycleClass =
|
|
887
|
+
job.status === "active" || job.status === "pending"
|
|
888
|
+
? " ok"
|
|
889
|
+
: job.status === "failed" || job.status === "invalid"
|
|
890
|
+
? " danger"
|
|
891
|
+
: "";
|
|
892
|
+
const detail = job.lastRunAt
|
|
893
|
+
? `<span class="schedule-detail">Last attempt ${esc(timeAgo(job.lastRunAt))}</span>`
|
|
894
|
+
: '<span class="schedule-detail">Not run yet</span>';
|
|
895
|
+
const delivery = job.lastDeliveryStatus
|
|
896
|
+
? `<span class="tag${job.lastDeliveryStatus === "sent" ? " ok" : " danger"}">Last: ${esc(job.lastDeliveryStatus)}</span>`
|
|
897
|
+
: "";
|
|
898
|
+
const error = job.lastError
|
|
899
|
+
? `<span class="schedule-error" title="${esc(job.lastError)}">${esc(job.lastError)}</span>`
|
|
900
|
+
: "";
|
|
901
|
+
|
|
902
|
+
return `<div class="schedule-state">
|
|
903
|
+
<span class="tag${lifecycleClass}">${esc(job.status)}</span>
|
|
904
|
+
${delivery}${detail}${error}
|
|
905
|
+
</div>`;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function scheduleActions(job) {
|
|
909
|
+
const retryable =
|
|
910
|
+
job.status === "failed" || job.lastDeliveryStatus === "failed";
|
|
911
|
+
return `<div class="table-actions">
|
|
912
|
+
${
|
|
913
|
+
retryable
|
|
914
|
+
? `<button class="btn btn-sm" type="button" data-retry-schedule="${esc(job.id)}">Retry now</button>`
|
|
915
|
+
: ""
|
|
916
|
+
}
|
|
917
|
+
<button class="btn btn-sm btn-danger" type="button" data-drop-schedule="${esc(job.id)}">Delete</button>
|
|
918
|
+
</div>`;
|
|
919
|
+
}
|
|
920
|
+
|
|
875
921
|
const TABLES = {
|
|
876
922
|
debts: {
|
|
877
923
|
endpoint: "/debts",
|
|
@@ -929,15 +975,13 @@
|
|
|
929
975
|
schedules: {
|
|
930
976
|
endpoint: "/schedules",
|
|
931
977
|
key: "schedules",
|
|
932
|
-
columns: ["Target", "
|
|
978
|
+
columns: ["Target", "Schedule", "Message", "Delivery", "Actions"],
|
|
933
979
|
row: (job) => [
|
|
934
980
|
`<code>${esc(shortJid(job.targetJid))}</code>`,
|
|
935
|
-
job.
|
|
936
|
-
? `<code>${esc(job.cronString || "—")}</code>`
|
|
937
|
-
: esc(new Date(job.date).toLocaleString()),
|
|
981
|
+
`<span class="wrap">${esc(job.when || job.cronString || "—")}</span>`,
|
|
938
982
|
`<span class="wrap">${esc(String(job.message ?? "").slice(0, 120))}</span>`,
|
|
939
|
-
|
|
940
|
-
|
|
983
|
+
scheduleState(job),
|
|
984
|
+
scheduleActions(job),
|
|
941
985
|
],
|
|
942
986
|
},
|
|
943
987
|
users: {
|
|
@@ -983,6 +1027,24 @@
|
|
|
983
1027
|
|
|
984
1028
|
function initDataActions() {
|
|
985
1029
|
$("#data-table").addEventListener("click", (event) => {
|
|
1030
|
+
const retryButton = event.target.closest("[data-retry-schedule]");
|
|
1031
|
+
if (retryButton) {
|
|
1032
|
+
guard(async () => {
|
|
1033
|
+
retryButton.disabled = true;
|
|
1034
|
+
retryButton.textContent = "Retrying…";
|
|
1035
|
+
try {
|
|
1036
|
+
await api(
|
|
1037
|
+
`/schedules/${encodeURIComponent(retryButton.dataset.retrySchedule)}/retry`,
|
|
1038
|
+
{ method: "POST" }
|
|
1039
|
+
);
|
|
1040
|
+
toast("Scheduled message sent", "ok");
|
|
1041
|
+
} finally {
|
|
1042
|
+
await loadTable();
|
|
1043
|
+
}
|
|
1044
|
+
})();
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
986
1048
|
const button = event.target.closest("[data-drop-schedule]");
|
|
987
1049
|
if (!button) return;
|
|
988
1050
|
guard(async () => {
|
|
@@ -998,9 +1060,11 @@
|
|
|
998
1060
|
const button = event.target.closest("[data-table]");
|
|
999
1061
|
if (!button) return;
|
|
1000
1062
|
dataTab = button.dataset.table;
|
|
1001
|
-
$$("#data-tabs .btn").forEach((tab) =>
|
|
1002
|
-
|
|
1003
|
-
|
|
1063
|
+
$$("#data-tabs .btn").forEach((tab) => {
|
|
1064
|
+
const active = tab.dataset.table === dataTab;
|
|
1065
|
+
tab.classList.toggle("btn-primary", active);
|
|
1066
|
+
tab.setAttribute("aria-selected", String(active));
|
|
1067
|
+
});
|
|
1004
1068
|
loadTable();
|
|
1005
1069
|
});
|
|
1006
1070
|
}
|
|
@@ -1018,7 +1082,7 @@
|
|
|
1018
1082
|
<td><code>${esc(user.phone || shortJid(user.jid))}</code></td>
|
|
1019
1083
|
<td>${esc(user.displayName || "—")}</td>
|
|
1020
1084
|
<td>
|
|
1021
|
-
<button class="btn btn-sm btn-danger" data-revoke="${esc(user.phone || shortJid(user.jid))}"
|
|
1085
|
+
<button class="btn btn-sm btn-danger" type="button" data-revoke="${esc(user.phone || shortJid(user.jid))}"
|
|
1022
1086
|
data-role="${esc(role)}">Revoke</button>
|
|
1023
1087
|
</td>
|
|
1024
1088
|
</tr>`
|
|
@@ -1084,8 +1148,8 @@
|
|
|
1084
1148
|
<div class="inline">
|
|
1085
1149
|
<input type="password" id="${id}" placeholder="${setting.configured ? "•••••••• (set)" : "not set"}"
|
|
1086
1150
|
autocomplete="new-password"${setting.configured ? ' data-configured="1"' : ""} />
|
|
1087
|
-
<button class="btn btn-sm" data-save-setting="${esc(setting.key)}">Save</button>
|
|
1088
|
-
${setting.configured ? `<button class="btn btn-sm btn-danger" data-clear-setting="${esc(setting.key)}">Clear</button>` : ""}
|
|
1151
|
+
<button class="btn btn-sm" type="button" data-save-setting="${esc(setting.key)}">Save</button>
|
|
1152
|
+
${setting.configured ? `<button class="btn btn-sm btn-danger" type="button" data-clear-setting="${esc(setting.key)}">Clear</button>` : ""}
|
|
1089
1153
|
</div>`;
|
|
1090
1154
|
} else if (setting.type === "bool") {
|
|
1091
1155
|
control = `
|
|
@@ -1094,7 +1158,7 @@
|
|
|
1094
1158
|
<input type="checkbox" id="${id}" ${setting.value ? "checked" : ""} />
|
|
1095
1159
|
<span class="track"></span>
|
|
1096
1160
|
</label>
|
|
1097
|
-
<button class="btn btn-sm" data-save-setting="${esc(setting.key)}">Save</button>
|
|
1161
|
+
<button class="btn btn-sm" type="button" data-save-setting="${esc(setting.key)}">Save</button>
|
|
1098
1162
|
</div>`;
|
|
1099
1163
|
} else if (Array.isArray(setting.choices) && setting.choices.length) {
|
|
1100
1164
|
control = `
|
|
@@ -1107,7 +1171,7 @@
|
|
|
1107
1171
|
)
|
|
1108
1172
|
.join("")}
|
|
1109
1173
|
</select>
|
|
1110
|
-
<button class="btn btn-sm" data-save-setting="${esc(setting.key)}">Save</button>
|
|
1174
|
+
<button class="btn btn-sm" type="button" data-save-setting="${esc(setting.key)}">Save</button>
|
|
1111
1175
|
</div>`;
|
|
1112
1176
|
} else {
|
|
1113
1177
|
control = `
|
|
@@ -1116,18 +1180,18 @@
|
|
|
1116
1180
|
value="${esc(setting.value)}"
|
|
1117
1181
|
${setting.min !== null ? `min="${setting.min}"` : ""}
|
|
1118
1182
|
${setting.max !== null ? `max="${setting.max}"` : ""} />
|
|
1119
|
-
<button class="btn btn-sm" data-save-setting="${esc(setting.key)}">Save</button>
|
|
1183
|
+
<button class="btn btn-sm" type="button" data-save-setting="${esc(setting.key)}">Save</button>
|
|
1120
1184
|
</div>`;
|
|
1121
1185
|
}
|
|
1122
1186
|
|
|
1123
1187
|
return `
|
|
1124
|
-
<div
|
|
1125
|
-
<div class="inline
|
|
1126
|
-
<label for="${id}"
|
|
1188
|
+
<div class="setting-item">
|
|
1189
|
+
<div class="inline setting-label-row">
|
|
1190
|
+
<label for="${id}" class="setting-label">${esc(setting.label)}</label>
|
|
1127
1191
|
${source}${restart}
|
|
1128
1192
|
</div>
|
|
1129
1193
|
${control}
|
|
1130
|
-
${setting.hint ? `<div class="hint
|
|
1194
|
+
${setting.hint ? `<div class="hint setting-hint">${esc(setting.hint)}</div>` : ""}
|
|
1131
1195
|
</div>`;
|
|
1132
1196
|
}
|
|
1133
1197
|
|
package/scheduler.cjs
CHANGED
|
@@ -30,6 +30,7 @@ function saveScheduledJob(job) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
const runningTasks = new Map();
|
|
33
|
+
const deliveriesInFlight = new Set();
|
|
33
34
|
let cleanupScheduled = false;
|
|
34
35
|
|
|
35
36
|
function trimMediaDirectory() {
|
|
@@ -128,24 +129,70 @@ function stopTask(jobId) {
|
|
|
128
129
|
runningTasks.delete(jobId);
|
|
129
130
|
}
|
|
130
131
|
|
|
131
|
-
function
|
|
132
|
-
|
|
132
|
+
function deliveryErrorMessage(error) {
|
|
133
|
+
const message = error instanceof Error ? error.message : String(error ?? "Unknown error");
|
|
134
|
+
return message.replace(/\s+/g, " ").trim().slice(0, 240) || "Unknown error";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function recordDelivery(jobId, status, runAt, error = null) {
|
|
138
|
+
try {
|
|
139
|
+
storage.setScheduleDelivery(jobId, status, runAt, error);
|
|
140
|
+
} catch (stateError) {
|
|
141
|
+
logger.error(
|
|
142
|
+
{ err: stateError, jobId },
|
|
143
|
+
"[Scheduler] failed to record scheduled delivery state"
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function deliverScheduledJob(sock, job) {
|
|
149
|
+
if (deliveriesInFlight.has(job.id)) {
|
|
150
|
+
logger.warn(`[Scheduler] Job ${job.id} is already being delivered — skipping overlap`);
|
|
151
|
+
return { ok: false, reason: "in_flight", skipped: true };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
deliveriesInFlight.add(job.id);
|
|
155
|
+
const runAt = Date.now();
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
await sock.sendMessage(job.targetJid, {
|
|
159
|
+
text: `*رسالة مجدولة 🗓️*\n\n${job.message}`,
|
|
160
|
+
});
|
|
161
|
+
recordDelivery(job.id, "sent", runAt);
|
|
162
|
+
logger.info(`[Scheduler] Executed job ${job.id} -> ${job.targetJid}`);
|
|
163
|
+
return { ok: true, reason: "sent" };
|
|
164
|
+
} catch (error) {
|
|
165
|
+
const message = deliveryErrorMessage(error);
|
|
166
|
+
recordDelivery(job.id, "failed", runAt, message);
|
|
167
|
+
logger.error(
|
|
168
|
+
{ err: error, jobId: job.id },
|
|
169
|
+
"[Scheduler] failed to send scheduled message"
|
|
170
|
+
);
|
|
171
|
+
return { ok: false, reason: "delivery_failed", error: message };
|
|
172
|
+
} finally {
|
|
173
|
+
deliveriesInFlight.delete(job.id);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function executeScheduledJob(sock, job) {
|
|
178
|
+
const result = await deliverScheduledJob(sock, job);
|
|
133
179
|
|
|
134
|
-
|
|
180
|
+
if (job.type !== "recurring" && !result.skipped) {
|
|
135
181
|
try {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
});
|
|
139
|
-
logger.info(`[Scheduler] Executed job ${job.id} -> ${job.targetJid}`);
|
|
140
|
-
} catch (err) {
|
|
141
|
-
// مفيش حد ماسك الـ promise دي: من غير الـ catch الرفض بيطلع
|
|
142
|
-
// unhandledRejection ويوقّف البوت.
|
|
182
|
+
updateJobStatus(job.id, result.ok ? "sent" : "failed");
|
|
183
|
+
} catch (error) {
|
|
143
184
|
logger.error(
|
|
144
|
-
{ err, jobId: job.id },
|
|
145
|
-
"[Scheduler] failed to
|
|
185
|
+
{ err: error, jobId: job.id },
|
|
186
|
+
"[Scheduler] failed to record one-off schedule status"
|
|
146
187
|
);
|
|
147
188
|
}
|
|
148
|
-
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function scheduleNewJob(sock, job) {
|
|
195
|
+
stopTask(job.id);
|
|
149
196
|
|
|
150
197
|
if (job.type === "recurring") {
|
|
151
198
|
if (!job.cronString || !cron.validate(job.cronString)) {
|
|
@@ -158,7 +205,9 @@ function scheduleNewJob(sock, job) {
|
|
|
158
205
|
|
|
159
206
|
runningTasks.set(
|
|
160
207
|
job.id,
|
|
161
|
-
cron.schedule(job.cronString,
|
|
208
|
+
cron.schedule(job.cronString, () => executeScheduledJob(sock, job), {
|
|
209
|
+
timezone: settings.get("bot_timezone"),
|
|
210
|
+
})
|
|
162
211
|
);
|
|
163
212
|
} else {
|
|
164
213
|
const when = new Date(job.date).getTime();
|
|
@@ -179,9 +228,11 @@ function scheduleNewJob(sock, job) {
|
|
|
179
228
|
runningTasks.set(
|
|
180
229
|
job.id,
|
|
181
230
|
scheduleAt(when, async () => {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
231
|
+
try {
|
|
232
|
+
await executeScheduledJob(sock, job);
|
|
233
|
+
} finally {
|
|
234
|
+
runningTasks.delete(job.id);
|
|
235
|
+
}
|
|
185
236
|
})
|
|
186
237
|
);
|
|
187
238
|
}
|
|
@@ -190,6 +241,21 @@ function scheduleNewJob(sock, job) {
|
|
|
190
241
|
return true;
|
|
191
242
|
}
|
|
192
243
|
|
|
244
|
+
async function retryScheduledJob(sock, jobId) {
|
|
245
|
+
const job = storage.getSchedule(jobId);
|
|
246
|
+
if (!job) return { ok: false, reason: "not_found" };
|
|
247
|
+
|
|
248
|
+
const retryable =
|
|
249
|
+
job.status === "failed" ||
|
|
250
|
+
(job.type === "recurring" &&
|
|
251
|
+
job.status === "active" &&
|
|
252
|
+
job.lastDeliveryStatus === "failed");
|
|
253
|
+
if (!retryable) return { ok: false, reason: "not_failed", job };
|
|
254
|
+
|
|
255
|
+
const result = await executeScheduledJob(sock, job);
|
|
256
|
+
return { ...result, job: storage.getSchedule(jobId) };
|
|
257
|
+
}
|
|
258
|
+
|
|
193
259
|
function deleteScheduledJob(jobId) {
|
|
194
260
|
stopTask(jobId);
|
|
195
261
|
storage.deleteSchedule(jobId);
|
|
@@ -205,6 +271,7 @@ module.exports = {
|
|
|
205
271
|
initializeScheduledJobs,
|
|
206
272
|
stopAllScheduledJobs,
|
|
207
273
|
scheduleNewJob,
|
|
274
|
+
retryScheduledJob,
|
|
208
275
|
getScheduledJobs,
|
|
209
276
|
saveScheduledJob,
|
|
210
277
|
deleteScheduledJob,
|
package/src/bootstrap/core.js
CHANGED
|
@@ -5,17 +5,21 @@
|
|
|
5
5
|
// entirely optional. `levix headless` runs this file and nothing else, which is
|
|
6
6
|
// what makes "no panel port is opened" a fact rather than a promise.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
8
|
+
// A brand-new panel install stays idle until somebody presses Start on the
|
|
9
|
+
// Connection screen, so it does not generate pairing attempts nobody asked
|
|
10
|
+
// for. Once the install has been paired, process restarts resume that saved
|
|
11
|
+
// session automatically. Headless has no Start button, so it asks for
|
|
12
|
+
// `autoStart` and starts even when pairing is still required.
|
|
13
13
|
|
|
14
14
|
import { createRequire } from "module";
|
|
15
15
|
import { WhatsAppSession } from "../core/session.js";
|
|
16
16
|
import { loadCommands, getLoadedCommands } from "../handlers/command.handler.js";
|
|
17
17
|
import { initStore } from "../db/store.esm.js";
|
|
18
18
|
import { deleteQrCode } from "../utils/storage.esm.js";
|
|
19
|
+
import {
|
|
20
|
+
hasPairedCredentials,
|
|
21
|
+
sessionStartupPolicy,
|
|
22
|
+
} from "./session-startup-policy.js";
|
|
19
23
|
|
|
20
24
|
const require = createRequire(import.meta.url);
|
|
21
25
|
const logger = require("../utils/logger.cjs");
|
|
@@ -29,8 +33,9 @@ const { initializeScheduledJobs, stopAllScheduledJobs } = require("../../schedul
|
|
|
29
33
|
* Start the bot.
|
|
30
34
|
*
|
|
31
35
|
* @param {object} [options]
|
|
32
|
-
* @param {boolean} [options.autoStart] -
|
|
33
|
-
* Headless
|
|
36
|
+
* @param {boolean} [options.autoStart] - force a WhatsApp start even when the
|
|
37
|
+
* install is not paired yet. Headless uses this; panel mode resumes only an
|
|
38
|
+
* already-paired session.
|
|
34
39
|
* @returns {Promise<{ session: object, commandCount: number, wasPaired: boolean }>}
|
|
35
40
|
*/
|
|
36
41
|
export async function bootstrapCore({ autoStart = false } = {}) {
|
|
@@ -43,9 +48,9 @@ export async function bootstrapCore({ autoStart = false } = {}) {
|
|
|
43
48
|
// bot's state, not next to the code.
|
|
44
49
|
ensureDataDir("media");
|
|
45
50
|
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
const wasPaired = store.
|
|
51
|
+
// A `creds` row alone is not enough: Baileys creates one before pairing.
|
|
52
|
+
// `me.id` is the durable proof that this install was actually linked.
|
|
53
|
+
const wasPaired = hasPairedCredentials(store.authRead("creds"));
|
|
49
54
|
|
|
50
55
|
await initStore();
|
|
51
56
|
await loadCommands();
|
|
@@ -64,7 +69,8 @@ export async function bootstrapCore({ autoStart = false } = {}) {
|
|
|
64
69
|
log: logger,
|
|
65
70
|
});
|
|
66
71
|
|
|
67
|
-
|
|
72
|
+
const startup = sessionStartupPolicy({ autoStart, wasPaired });
|
|
73
|
+
if (startup.start) await session.start({ reason: startup.reason });
|
|
68
74
|
|
|
69
75
|
return {
|
|
70
76
|
session,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Decide whether the WhatsApp session should be started during process boot.
|
|
2
|
+
//
|
|
3
|
+
// Baileys writes a `creds` row as soon as auth state is initialized, even before
|
|
4
|
+
// a QR is successfully paired. Only `creds.me.id` proves that this installation
|
|
5
|
+
// was actually linked to a WhatsApp account.
|
|
6
|
+
export function hasPairedCredentials(rawCreds) {
|
|
7
|
+
if (!rawCreds) return false;
|
|
8
|
+
try {
|
|
9
|
+
const creds = typeof rawCreds === "string" ? JSON.parse(rawCreds) : rawCreds;
|
|
10
|
+
return Boolean(creds?.me?.id);
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Panel mode must not create a fresh pairing attempt on a brand-new install,
|
|
17
|
+
// but once a real pairing exists a process restart should restore that session
|
|
18
|
+
// automatically. Headless has no Start button, so it always forces a start.
|
|
19
|
+
export function sessionStartupPolicy({ autoStart = false, wasPaired = false } = {}) {
|
|
20
|
+
if (autoStart) return { start: true, reason: "autostart" };
|
|
21
|
+
if (wasPaired) return { start: true, reason: "resume-paired" };
|
|
22
|
+
return { start: false, reason: null };
|
|
23
|
+
}
|
|
@@ -4,6 +4,7 @@ const {
|
|
|
4
4
|
} = require("../../scheduler.cjs");
|
|
5
5
|
const { randomUUID } = require("node:crypto");
|
|
6
6
|
const { defaultTimezone } = require("../utils/datetime.cjs");
|
|
7
|
+
const { parseRecurringArgs } = require("../utils/recurrence.cjs");
|
|
7
8
|
|
|
8
9
|
module.exports = {
|
|
9
10
|
name: "autoschedule",
|
|
@@ -14,54 +15,27 @@ module.exports = {
|
|
|
14
15
|
async execute(sock, msg, args) {
|
|
15
16
|
const creatorJid = msg.key.participant || msg.key.remoteJid;
|
|
16
17
|
const targetJid = msg.key.remoteJid;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
// من غير الفحص ده أي كتابة غلط ("abc:xyz") بتتحوّل لكرون باظ يتخزّن في
|
|
29
|
-
// الملف، وبعدين كل تشغيل للبوت بيقع وهو بيحاول يجدوله.
|
|
30
|
-
const timeMatch = /^(\d{1,2}):(\d{2})$/.exec(String(time).trim());
|
|
31
|
-
const hour = timeMatch && Number(timeMatch[1]);
|
|
32
|
-
const minute = timeMatch && Number(timeMatch[2]);
|
|
33
|
-
|
|
34
|
-
if (!timeMatch || hour > 23 || minute > 59) {
|
|
35
|
-
return await sock.sendMessage(creatorJid, {
|
|
36
|
-
text: "الوقت غير صالح. استخدم صيغة `HH:mm` (مثال: `09:30`).",
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
let cronString = "";
|
|
41
|
-
|
|
42
|
-
if (type === "daily") {
|
|
43
|
-
cronString = `${minute} ${hour} * * *`;
|
|
44
|
-
} else if (type === "weekly") {
|
|
45
|
-
// This part can be enhanced later to support specific days like 'monday'
|
|
46
|
-
// For now, let's keep it simple. Example: !autoschedule weekly 1 10:30 "..." for Monday 10:30
|
|
47
|
-
const dayOfWeek = args[1];
|
|
48
|
-
const weeklyTime = args[2];
|
|
49
|
-
const weeklyMessage = args.slice(3).join(" ");
|
|
50
|
-
// ... logic to build weekly cron string ...
|
|
51
|
-
return await sock.sendMessage(creatorJid, {
|
|
52
|
-
text: "جدولة أسبوعية قيد التطوير.",
|
|
53
|
-
});
|
|
54
|
-
} else {
|
|
18
|
+
const parsed = parseRecurringArgs(args);
|
|
19
|
+
|
|
20
|
+
if (parsed.error) {
|
|
21
|
+
const errors = {
|
|
22
|
+
usage:
|
|
23
|
+
"الصيغة غير صحيحة. استخدم:\n`!autoschedule daily HH:mm رسالتك`\n`!autoschedule weekly day HH:mm رسالتك`",
|
|
24
|
+
type: "النوع غير مدعوم. استخدم: `daily` أو `weekly`.",
|
|
25
|
+
day: "اليوم غير صالح. استخدم اسم اليوم بالعربية أو الإنجليزية، أو رقمًا من 0 إلى 7 (0 و7 للأحد).",
|
|
26
|
+
time: "الوقت غير صالح. استخدم صيغة `HH:mm` (مثال: `09:30`).",
|
|
27
|
+
message: "اكتب الرسالة التي تريد جدولتها بعد الوقت.",
|
|
28
|
+
};
|
|
55
29
|
return await sock.sendMessage(creatorJid, {
|
|
56
|
-
text:
|
|
30
|
+
text: errors[parsed.error],
|
|
57
31
|
});
|
|
58
32
|
}
|
|
59
33
|
|
|
60
34
|
const newJob = {
|
|
61
35
|
id: randomUUID(),
|
|
62
36
|
type: "recurring",
|
|
63
|
-
cronString: cronString,
|
|
64
|
-
message: message,
|
|
37
|
+
cronString: parsed.cronString,
|
|
38
|
+
message: parsed.message,
|
|
65
39
|
targetJid: targetJid,
|
|
66
40
|
creatorJid: creatorJid,
|
|
67
41
|
status: "active",
|
|
@@ -77,7 +51,10 @@ module.exports = {
|
|
|
77
51
|
saveScheduledJob(newJob);
|
|
78
52
|
|
|
79
53
|
await sock.sendMessage(creatorJid, {
|
|
80
|
-
text:
|
|
54
|
+
text:
|
|
55
|
+
parsed.type === "weekly"
|
|
56
|
+
? `✅ تم جدولة الرسالة أسبوعيًا يوم ${args[1]} الساعة ${parsed.time} (${defaultTimezone()})`
|
|
57
|
+
: `✅ تم جدولة الرسالة يوميًا الساعة ${parsed.time} (${defaultTimezone()})`,
|
|
81
58
|
});
|
|
82
59
|
},
|
|
83
60
|
};
|
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
// be able to change what the product is called or who wrote it, and should not
|
|
17
17
|
// have to look at product metadata to edit their own prompt.
|
|
18
18
|
//
|
|
19
|
-
// This is an open-source project. Nothing here is obfuscated
|
|
20
|
-
//
|
|
21
|
-
//
|
|
19
|
+
// This is an open-source project. Nothing here is obfuscated or secret. Public
|
|
20
|
+
// credit facts also live in brand.cjs so the footer can render them; the full
|
|
21
|
+
// identity object and its instruction block remain code-owned rather than
|
|
22
|
+
// operator-configurable, and are not reachable from the panel.
|
|
22
23
|
|
|
23
24
|
const IDENTITY = Object.freeze({
|
|
24
25
|
product: "Levix",
|
package/src/config/brand.cjs
CHANGED
|
@@ -16,9 +16,12 @@ const BRAND = Object.freeze({
|
|
|
16
16
|
tagline: "Personal WhatsApp Bot",
|
|
17
17
|
taglineAr: "بوت واتساب شخصي",
|
|
18
18
|
|
|
19
|
+
copyrightYear: 2026,
|
|
19
20
|
developer: "Abdelrhman Diab",
|
|
20
21
|
developerAr: "عبدالرحمن دياب",
|
|
22
|
+
developerSite: "https://abdelrhman.leviro.net",
|
|
21
23
|
studio: "Leviro",
|
|
24
|
+
studioSite: "https://leviro.net",
|
|
22
25
|
|
|
23
26
|
repo: "https://github.com/Abdodiab2005/levix",
|
|
24
27
|
});
|
package/src/db/db.cjs
CHANGED
|
@@ -173,6 +173,14 @@ const MIGRATIONS = [
|
|
|
173
173
|
);
|
|
174
174
|
`);
|
|
175
175
|
},
|
|
176
|
+
// v2 — delivery history for truthful schedule status and manual retries.
|
|
177
|
+
(database) => {
|
|
178
|
+
database.exec(`
|
|
179
|
+
ALTER TABLE schedules ADD COLUMN last_run_at INTEGER;
|
|
180
|
+
ALTER TABLE schedules ADD COLUMN last_delivery_status TEXT;
|
|
181
|
+
ALTER TABLE schedules ADD COLUMN last_error TEXT;
|
|
182
|
+
`);
|
|
183
|
+
},
|
|
176
184
|
];
|
|
177
185
|
|
|
178
186
|
function migrate(database, migrations = MIGRATIONS) {
|