@laburen/openclaw-plugin-whatsapp-api 0.2.3 → 0.3.1
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 +12 -3
- package/index.d.ts +8 -3
- package/index.js +153 -7
- package/package.json +2 -1
- package/src/scripts/check.sh +46 -0
- package/src/scripts/setup.sh +49 -0
package/README.md
CHANGED
|
@@ -7,9 +7,6 @@ WhatsApp Cloud API channel for OpenClaw: your router receives Meta webhooks and
|
|
|
7
7
|
```bash
|
|
8
8
|
openclaw plugins install @laburen/openclaw-plugin-whatsapp-api
|
|
9
9
|
```
|
|
10
|
-
|
|
11
|
-
If you install from a local folder instead, copy the package into your OpenClaw extensions root and enable it in config (same plugin id: `whatsapp-api`).
|
|
12
|
-
|
|
13
10
|
## Setup
|
|
14
11
|
|
|
15
12
|
1. In [Meta for Developers](https://developers.facebook.com/), create or open an app with **WhatsApp** product enabled and note your **Phone number ID** and a long-lived **System User** or **Temporary** access token with `whatsapp_business_messaging` (and webhook permissions as required by your setup).
|
|
@@ -47,6 +44,18 @@ openclaw gateway restart
|
|
|
47
44
|
|
|
48
45
|
- Transient failures (`429`, `5xx`, timeouts) are retried according to `maxRetries`, `retryBackoffMs`, and `requestTimeoutMs`.
|
|
49
46
|
|
|
47
|
+
### WhatsApp 24-hour context window (reminder)
|
|
48
|
+
|
|
49
|
+
WhatsApp Cloud API lets you send **session** messages (normal replies) only within about **24 hours** of the user’s last inbound message. After that window, outbound traffic is limited to **approved templates** until the user writes again.
|
|
50
|
+
|
|
51
|
+
This plugin can nudge you before the window closes:
|
|
52
|
+
|
|
53
|
+
1. **Snapshot on inbound:** For each user message handled by the channel, a hook writes `<OpenClaw state dir>/whatsapp-api/last-inbound-message.json` with `from`, `content`, `timestamp`, channel/account/conversation ids when present, and **`notified: false`**. That reset means a new user message clears any previous “we already warned” state.
|
|
54
|
+
2. **Cron from the host:** On service start, the plugin runs `setup.sh` (shipped under `src/scripts/` in the npm package). The script registers an OpenClaw cron job that periodically invokes `check.sh` via the exec tool (see the job’s description in `setup.sh`).
|
|
55
|
+
3. **`check.sh` behavior:** It reads the JSON file, skips if missing or `notified` is already `true`, and compares elapsed time since `timestamp` to a threshold (default **23 hours**). If the threshold is met, it runs `openclaw message send` to the user’s phone (parsed from `from`) with a configurable warning text, then sets **`notified: true`** in the same file so the warning is not sent again until the next inbound message.
|
|
56
|
+
|
|
57
|
+
Useful environment variables for the scripts: `OPENCLAW_DIR` (defaults to `/home/<user>/.openclaw`), `THRESHOLD_MINUTES`, `WARNING_MESSAGE`, and `EVERY` for the cron interval when installing (default `30m`). To remove the job: `bash /path/to/node_modules/@laburen/openclaw-plugin-whatsapp-api/src/scripts/setup.sh --uninstall` (adjust path to your install).
|
|
58
|
+
|
|
50
59
|
**Inbound webhook (from your router to OpenClaw)**
|
|
51
60
|
|
|
52
61
|
| Item | Detail |
|
package/index.d.ts
CHANGED
|
@@ -2,16 +2,21 @@ import { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
|
2
2
|
|
|
3
3
|
//#region index.d.ts
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* OpenClaw plugin metadata and registration hook. Default export of this package.
|
|
6
|
+
*
|
|
7
|
+
* Channel behaviour is implemented by {@link createWhatsAppApiChannel}; hooks and
|
|
8
|
+
* services are aggregated via {@link registerAllPluginHooks} and
|
|
9
|
+
* {@link registerAllPluginServices}.
|
|
6
10
|
*/
|
|
7
11
|
declare const plugin: {
|
|
8
12
|
id: string;
|
|
9
13
|
name: string;
|
|
10
14
|
description: string;
|
|
11
15
|
/**
|
|
12
|
-
*
|
|
16
|
+
* Called by OpenClaw when the plugin loads: publishes the channel, wires the
|
|
17
|
+
* global plugin API holder, then registers pipeline hooks and plugin services.
|
|
13
18
|
*
|
|
14
|
-
* @param api -
|
|
19
|
+
* @param api - Plugin API injected by the host (config, runtime, registration helpers)
|
|
15
20
|
*/
|
|
16
21
|
register(api: OpenClawPluginApi): void;
|
|
17
22
|
};
|
package/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import fs, { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
4
6
|
//#region src/core/accounts.ts
|
|
5
7
|
const CHANNEL_ID$1 = "whatsapp-api";
|
|
6
8
|
const DEFAULT_WEBHOOK_PATH = "/webhook/whatsapp-api";
|
|
@@ -1079,7 +1081,7 @@ async function handleWhatsAppApiWebhook(params) {
|
|
|
1079
1081
|
//#endregion
|
|
1080
1082
|
//#region src/channel/plugin.ts
|
|
1081
1083
|
const CHANNEL_ID = "whatsapp-api";
|
|
1082
|
-
const log$
|
|
1084
|
+
const log$3 = waLogger("channel");
|
|
1083
1085
|
const DEFAULT_ACCOUNT_ID = "default";
|
|
1084
1086
|
/**
|
|
1085
1087
|
* Resolves when `signal` aborts, or never if `signal` is undefined.
|
|
@@ -1148,7 +1150,7 @@ async function dispatchInboundToAgent(params) {
|
|
|
1148
1150
|
const mediaAttachment = await downloadInboundMediaAttachment({
|
|
1149
1151
|
account,
|
|
1150
1152
|
message,
|
|
1151
|
-
log: { warn: (msg) => log$
|
|
1153
|
+
log: { warn: (msg) => log$3.warn(msg) }
|
|
1152
1154
|
});
|
|
1153
1155
|
const replyApi = rt.channel.reply;
|
|
1154
1156
|
const ctxPayload = replyApi.finalizeInboundContext({
|
|
@@ -1188,17 +1190,17 @@ async function dispatchInboundToAgent(params) {
|
|
|
1188
1190
|
account
|
|
1189
1191
|
});
|
|
1190
1192
|
if (sent.length === 0) {
|
|
1191
|
-
log$
|
|
1193
|
+
log$3.info("skipping outbound: reply payload empty", { messageId: message.messageId });
|
|
1192
1194
|
return;
|
|
1193
1195
|
}
|
|
1194
|
-
log$
|
|
1196
|
+
log$3.info(`sent ${sent.length} outbound message(s)`, {
|
|
1195
1197
|
to: message.from,
|
|
1196
1198
|
messageId: message.messageId
|
|
1197
1199
|
});
|
|
1198
1200
|
onOutbound?.();
|
|
1199
1201
|
},
|
|
1200
1202
|
onError: (err, info) => {
|
|
1201
|
-
log$
|
|
1203
|
+
log$3.error(`${info?.kind ?? "reply"} dispatch failed`, {
|
|
1202
1204
|
accountId,
|
|
1203
1205
|
messageId: message.messageId,
|
|
1204
1206
|
error: String(err)
|
|
@@ -1335,10 +1337,152 @@ function createWhatsAppApiChannel(api) {
|
|
|
1335
1337
|
};
|
|
1336
1338
|
}
|
|
1337
1339
|
//#endregion
|
|
1340
|
+
//#region src/plugin/plugin-state-dir.ts
|
|
1341
|
+
/**
|
|
1342
|
+
* SPDX-License-Identifier: MIT
|
|
1343
|
+
*
|
|
1344
|
+
* OpenClaw provides `ctx.stateDir` only inside a registered service's `start`.
|
|
1345
|
+
* Pipeline hooks (e.g. `message_received`) do not receive that context, so any
|
|
1346
|
+
* hook that must read/write under the plugin state tree needs the same path.
|
|
1347
|
+
*
|
|
1348
|
+
* Only code that actually persists or reads plugin state should import this module.
|
|
1349
|
+
* Other hooks/services stay unaware of it.
|
|
1350
|
+
*/
|
|
1351
|
+
let serviceStateDir = null;
|
|
1352
|
+
/**
|
|
1353
|
+
* @returns The last `stateDir` set by a plugin service `start`, or `null` if
|
|
1354
|
+
* no service has started or after `stop` cleared it.
|
|
1355
|
+
*/
|
|
1356
|
+
function getPluginServiceStateDir() {
|
|
1357
|
+
return serviceStateDir;
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Updates the path exposed to hooks. Called from service lifecycle (typically
|
|
1361
|
+
* `start` / `stop` of whichever service owns the OpenClaw service context).
|
|
1362
|
+
*/
|
|
1363
|
+
function setPluginServiceStateDir(dir) {
|
|
1364
|
+
serviceStateDir = dir;
|
|
1365
|
+
}
|
|
1366
|
+
//#endregion
|
|
1367
|
+
//#region src/utils/last-inbound-persistence/store.ts
|
|
1368
|
+
/**
|
|
1369
|
+
* SPDX-License-Identifier: MIT
|
|
1370
|
+
*
|
|
1371
|
+
* Atomic JSON persistence for {@link LastInboundMessageSnapshot} under the
|
|
1372
|
+
* plugin state directory. Shared util for hooks and services.
|
|
1373
|
+
*/
|
|
1374
|
+
function getLastInboundMessagePath(stateDir) {
|
|
1375
|
+
return path.join(stateDir, "whatsapp-api", "last-inbound-message.json");
|
|
1376
|
+
}
|
|
1377
|
+
/**
|
|
1378
|
+
* Persists the last inbound message snapshot (write-to-temp + rename).
|
|
1379
|
+
*/
|
|
1380
|
+
async function writeLastInboundMessage(stateDir, data) {
|
|
1381
|
+
const filePath = getLastInboundMessagePath(stateDir);
|
|
1382
|
+
const dir = path.dirname(filePath);
|
|
1383
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1384
|
+
const tmpPath = `${filePath}.tmp`;
|
|
1385
|
+
await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), "utf8");
|
|
1386
|
+
await fs.rename(tmpPath, filePath);
|
|
1387
|
+
}
|
|
1388
|
+
//#endregion
|
|
1389
|
+
//#region src/hooks/message-received/handlers/persist-last-inbound.ts
|
|
1390
|
+
/**
|
|
1391
|
+
* SPDX-License-Identifier: MIT
|
|
1392
|
+
*
|
|
1393
|
+
* Persist last inbound message snapshot for `check.sh` / cron window alerts.
|
|
1394
|
+
*/
|
|
1395
|
+
const log$2 = waLogger("message-received/persist-last-inbound");
|
|
1396
|
+
async function handlePersistLastInbound(event, ctx) {
|
|
1397
|
+
const stateDir = getPluginServiceStateDir();
|
|
1398
|
+
if (!stateDir) return;
|
|
1399
|
+
const now = Date.now();
|
|
1400
|
+
const inboundTimestamp = typeof event.timestamp === "number" && Number.isFinite(event.timestamp) ? event.timestamp : now;
|
|
1401
|
+
const payload = {
|
|
1402
|
+
from: event.from,
|
|
1403
|
+
content: event.content,
|
|
1404
|
+
timestamp: inboundTimestamp,
|
|
1405
|
+
channelId: ctx.channelId,
|
|
1406
|
+
accountId: ctx.accountId,
|
|
1407
|
+
conversationId: ctx.conversationId,
|
|
1408
|
+
notified: false
|
|
1409
|
+
};
|
|
1410
|
+
try {
|
|
1411
|
+
await writeLastInboundMessage(stateDir, payload);
|
|
1412
|
+
log$2.debug("last inbound message persisted");
|
|
1413
|
+
} catch (error) {
|
|
1414
|
+
log$2.warn(`failed to persist last inbound message: ${String(error)}`);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
//#endregion
|
|
1418
|
+
//#region src/hooks/message-received/register.ts
|
|
1419
|
+
function registerMessageReceivedHook(api) {
|
|
1420
|
+
api.on("message_received", async (event, ctx) => {
|
|
1421
|
+
await handlePersistLastInbound(event, ctx);
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
//#endregion
|
|
1425
|
+
//#region src/hooks/register.ts
|
|
1426
|
+
/**
|
|
1427
|
+
* Registers all plugin-managed OpenClaw hooks for this extension.
|
|
1428
|
+
*/
|
|
1429
|
+
function registerAllPluginHooks(api) {
|
|
1430
|
+
registerMessageReceivedHook(api);
|
|
1431
|
+
}
|
|
1432
|
+
//#endregion
|
|
1433
|
+
//#region src/services/context-window-check/register.ts
|
|
1434
|
+
const log$1 = waLogger("context-window-check");
|
|
1435
|
+
const SETUP_SH = path.join(path.dirname(fileURLToPath(import.meta.url)), "src", "scripts", "setup.sh");
|
|
1436
|
+
function runSetupSh() {
|
|
1437
|
+
return new Promise((resolve, reject) => {
|
|
1438
|
+
const child = spawn("bash", [SETUP_SH], {
|
|
1439
|
+
stdio: "inherit",
|
|
1440
|
+
env: process.env
|
|
1441
|
+
});
|
|
1442
|
+
child.on("error", reject);
|
|
1443
|
+
child.on("close", (code, signal) => {
|
|
1444
|
+
if (code === 0) resolve();
|
|
1445
|
+
else reject(/* @__PURE__ */ new Error(`setup.sh exited with code ${code ?? signal}`));
|
|
1446
|
+
});
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
/** Registers the `whatsapp-api` plugin service (state dir + cron setup). */
|
|
1450
|
+
function registerContextWindowCheckService(api) {
|
|
1451
|
+
api.registerService({
|
|
1452
|
+
id: "whatsapp-api",
|
|
1453
|
+
start: async (ctx) => {
|
|
1454
|
+
setPluginServiceStateDir(ctx.stateDir);
|
|
1455
|
+
log$1.info("service started");
|
|
1456
|
+
try {
|
|
1457
|
+
await runSetupSh();
|
|
1458
|
+
log$1.info("setup.sh finished");
|
|
1459
|
+
} catch (error) {
|
|
1460
|
+
log$1.error("setup.sh failed", { error: String(error) });
|
|
1461
|
+
}
|
|
1462
|
+
},
|
|
1463
|
+
stop: async (_ctx) => {
|
|
1464
|
+
setPluginServiceStateDir(null);
|
|
1465
|
+
log$1.info("service stopped");
|
|
1466
|
+
}
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
//#endregion
|
|
1470
|
+
//#region src/services/register.ts
|
|
1471
|
+
/**
|
|
1472
|
+
* Registers all plugin-managed OpenClaw services for this extension.
|
|
1473
|
+
*/
|
|
1474
|
+
function registerAllPluginServices(api) {
|
|
1475
|
+
registerContextWindowCheckService(api);
|
|
1476
|
+
}
|
|
1477
|
+
//#endregion
|
|
1338
1478
|
//#region index.ts
|
|
1339
1479
|
const log = waLogger("plugin");
|
|
1340
1480
|
/**
|
|
1341
|
-
*
|
|
1481
|
+
* OpenClaw plugin metadata and registration hook. Default export of this package.
|
|
1482
|
+
*
|
|
1483
|
+
* Channel behaviour is implemented by {@link createWhatsAppApiChannel}; hooks and
|
|
1484
|
+
* services are aggregated via {@link registerAllPluginHooks} and
|
|
1485
|
+
* {@link registerAllPluginServices}.
|
|
1342
1486
|
*/
|
|
1343
1487
|
const plugin = {
|
|
1344
1488
|
id: "whatsapp-api",
|
|
@@ -1348,6 +1492,8 @@ const plugin = {
|
|
|
1348
1492
|
setPluginApi(api);
|
|
1349
1493
|
api.registerChannel({ plugin: createWhatsAppApiChannel(api) });
|
|
1350
1494
|
log.info("plugin registered");
|
|
1495
|
+
registerAllPluginHooks(api);
|
|
1496
|
+
registerAllPluginServices(api);
|
|
1351
1497
|
}
|
|
1352
1498
|
};
|
|
1353
1499
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@laburen/openclaw-plugin-whatsapp-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "WhatsApp API channel plugin for OpenClaw",
|
|
6
6
|
"main": "index.js",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"index.js",
|
|
9
9
|
"index.d.ts",
|
|
10
10
|
"openclaw.plugin.json",
|
|
11
|
+
"src/scripts",
|
|
11
12
|
"README.md",
|
|
12
13
|
"LICENSE"
|
|
13
14
|
],
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# check.sh - WhatsApp context window notifier
|
|
3
|
+
# Reads last-inbound-message.json and sends a warning if the user hasn't
|
|
4
|
+
# written in more than THRESHOLD_MINUTES and notified=false.
|
|
5
|
+
|
|
6
|
+
OPENCLAW_DIR="${OPENCLAW_DIR:-/home/$(whoami)/.openclaw}"
|
|
7
|
+
LAST_MSG_FILE="$OPENCLAW_DIR/whatsapp-api/last-inbound-message.json"
|
|
8
|
+
# 23 hours in minutes
|
|
9
|
+
THRESHOLD_MINUTES="${THRESHOLD_MINUTES:-$((23 * 60))}"
|
|
10
|
+
WARNING_MESSAGE="${WARNING_MESSAGE:-Aviso: la ventana de contexto de WhatsApp esta por vencer. Si quieres seguir recibiendo mensajes, responde este chat para reabrir la ventana de 24 horas.}"
|
|
11
|
+
|
|
12
|
+
if [ ! -f "$LAST_MSG_FILE" ]; then
|
|
13
|
+
echo "[whatsapp-window-notifier] File not found: $LAST_MSG_FILE — skipping."
|
|
14
|
+
exit 0
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
TIMESTAMP=$(jq -r '.timestamp' "$LAST_MSG_FILE")
|
|
18
|
+
NOTIFIED=$(jq -r '.notified' "$LAST_MSG_FILE")
|
|
19
|
+
FROM=$(jq -r '.from' "$LAST_MSG_FILE")
|
|
20
|
+
|
|
21
|
+
# Extract phone number: "whatsapp-api:5493496460785" → "5493496460785"
|
|
22
|
+
PHONE=$(echo "$FROM" | sed 's/.*://')
|
|
23
|
+
|
|
24
|
+
if [ "$NOTIFIED" = "true" ]; then
|
|
25
|
+
echo "[whatsapp-window-notifier] Already notified, skipping."
|
|
26
|
+
exit 0
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
NOW_MS=$(date +%s%3N)
|
|
30
|
+
ELAPSED_MS=$((NOW_MS - TIMESTAMP))
|
|
31
|
+
ELAPSED_MIN=$((ELAPSED_MS / 60000))
|
|
32
|
+
|
|
33
|
+
echo "[whatsapp-window-notifier] Elapsed: ${ELAPSED_MIN}m / threshold: ${THRESHOLD_MINUTES}m / target: $PHONE"
|
|
34
|
+
|
|
35
|
+
if [ "$ELAPSED_MIN" -ge "$THRESHOLD_MINUTES" ]; then
|
|
36
|
+
openclaw message send \
|
|
37
|
+
--channel whatsapp-api \
|
|
38
|
+
--target "$PHONE" \
|
|
39
|
+
--message "$WARNING_MESSAGE"
|
|
40
|
+
|
|
41
|
+
TMP=$(mktemp)
|
|
42
|
+
jq '.notified = true' "$LAST_MSG_FILE" > "$TMP" && mv "$TMP" "$LAST_MSG_FILE"
|
|
43
|
+
echo "[whatsapp-window-notifier] Notification sent to $PHONE, notified=true set."
|
|
44
|
+
else
|
|
45
|
+
echo "[whatsapp-window-notifier] Not enough time elapsed, no action."
|
|
46
|
+
fi
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# setup.sh - Installs/uninstalls the whatsapp-window-notifier cron job.
|
|
3
|
+
# Run once per OpenClaw instance.
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# bash setup.sh → installs the cron
|
|
7
|
+
# bash setup.sh --uninstall → removes the cron
|
|
8
|
+
|
|
9
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
10
|
+
CHECK_SCRIPT="$SCRIPT_DIR/check.sh"
|
|
11
|
+
JOB_NAME="whatsapp-window-notifier"
|
|
12
|
+
EVERY="${EVERY:-30m}"
|
|
13
|
+
UNINSTALL=false
|
|
14
|
+
|
|
15
|
+
for arg in "$@"; do
|
|
16
|
+
case $arg in
|
|
17
|
+
--uninstall) UNINSTALL=true ;;
|
|
18
|
+
esac
|
|
19
|
+
done
|
|
20
|
+
|
|
21
|
+
if [ "$UNINSTALL" = "true" ]; then
|
|
22
|
+
JOB_ID=$(openclaw cron list --json 2>/dev/null | jq -r ".jobs[] | select(.name == \"$JOB_NAME\") | .id")
|
|
23
|
+
if [ -n "$JOB_ID" ]; then
|
|
24
|
+
openclaw cron rm "$JOB_ID"
|
|
25
|
+
echo "[$JOB_NAME] Cron job removed (id: $JOB_ID)."
|
|
26
|
+
else
|
|
27
|
+
echo "[$JOB_NAME] No cron job found to remove."
|
|
28
|
+
fi
|
|
29
|
+
exit 0
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
# Check if already installed
|
|
33
|
+
EXISTING=$(openclaw cron list --json 2>/dev/null | jq -r ".jobs[] | select(.name == \"$JOB_NAME\") | .id")
|
|
34
|
+
if [ -n "$EXISTING" ]; then
|
|
35
|
+
echo "[$JOB_NAME] Already installed (id: $EXISTING). Run with --uninstall first to reinstall."
|
|
36
|
+
exit 0
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
chmod +x "$CHECK_SCRIPT"
|
|
40
|
+
|
|
41
|
+
openclaw cron add \
|
|
42
|
+
--name "$JOB_NAME" \
|
|
43
|
+
--every "$EVERY" \
|
|
44
|
+
--session isolated \
|
|
45
|
+
--no-deliver \
|
|
46
|
+
--description "Notifies user when WhatsApp 24h context window is about to expire" \
|
|
47
|
+
--message "Run this bash script using the exec tool and report nothing to the user: $CHECK_SCRIPT"
|
|
48
|
+
|
|
49
|
+
echo "[$JOB_NAME] Cron job installed. Runs every $EVERY."
|