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.
@@ -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
- link.classList.toggle("active", link.dataset.view === target)
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="label">${esc(label)}</div>
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" style="font-size:.72rem;letter-spacing:.09em;text-transform:uppercase;color:var(--faint)">${esc(label)}</div>
263
- <div style="font-weight:600;margin-top:2px">${esc(value)}</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", "When", "Message", "Status", ""],
978
+ columns: ["Target", "Schedule", "Message", "Delivery", "Actions"],
933
979
  row: (job) => [
934
980
  `<code>${esc(shortJid(job.targetJid))}</code>`,
935
- job.type === "recurring"
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
- `<span class="tag${job.status === "active" || job.status === "pending" ? " ok" : ""}">${esc(job.status)}</span>`,
940
- `<button class="btn btn-sm btn-danger" data-drop-schedule="${esc(job.id)}">Delete</button>`,
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
- tab.classList.toggle("btn-primary", tab.dataset.table === dataTab)
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 style="margin-bottom:18px">
1125
- <div class="inline" style="margin-bottom:5px">
1126
- <label for="${id}" style="font-size:.86rem;font-weight:550">${esc(setting.label)}</label>
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" style="margin-top:5px">${esc(setting.hint)}</div>` : ""}
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 scheduleNewJob(sock, job) {
132
- stopTask(job.id);
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
- const send = async () => {
180
+ if (job.type !== "recurring" && !result.skipped) {
135
181
  try {
136
- await sock.sendMessage(job.targetJid, {
137
- text: `*رسالة مجدولة 🗓️*\n\n${job.message}`,
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 send scheduled message"
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, send, { timezone: settings.get("bot_timezone") })
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
- await send();
183
- updateJobStatus(job.id, "sent");
184
- runningTasks.delete(job.id);
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,
@@ -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
- // Note what this does NOT do: it does not dial WhatsApp. It builds the session
9
- // manager and hands it back. Panel mode leaves it idle until somebody presses
10
- // Start on the Connection screen; headless has nobody to press anything, so it
11
- // asks for `autoStart`. That is the whole of the difference between the two
12
- // modes — there is no `if (headless)` anywhere below this line.
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] - connect to WhatsApp straight away.
33
- * Headless does; the panel waits to be asked.
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
- // Whether this install has ever been paired decides what the terminal says
47
- // next, so read it before the socket has a chance to create credentials.
48
- const wasPaired = store.hasCredentials();
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
- if (autoStart) await session.start({ reason: "autostart" });
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
- const type = args[0]?.toLowerCase();
19
- const time = args[1];
20
- const message = args.slice(2).join(" ");
21
-
22
- if (!type || !time || !message) {
23
- return await sock.sendMessage(creatorJid, {
24
- text: "الصيغة غير صحيحة. استخدم:\n`!autoschedule daily HH:mm رسالتك`\n`!autoschedule weekly day HH:mm رسالتك`",
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: "النوع غير مدعوم. استخدم: `daily`",
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: `✅ تم جدولة رسالة يومية بنجاح في الساعة ${time} (${defaultTimezone()})`,
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 and nothing here
20
- // is a secret — the point is that it is code-owned rather than
21
- // operator-configurable, and that it is not reachable from the panel.
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",
@@ -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) {