feat(prayer): implement ai-independent prayer scheduling

Refactor prayer notification system to remove AI dependency for daily scheduling. Changes include:
- Added '--at', '--command', and '--delete-after' flags to 'picoclaw cron add' CLI.
- Rewrote 'prayer_notify.sh auto_schedule' to use CLI instead of direct JSON writes.
- Implemented Indonesia city-to-timezone mapping (WIB/WITA/WIT) for accurate scheduling.
- Added cron store reload in 'pkg/tools/cron.go' after command execution.
- Updated 'SKILL.md' with new setup instructions.
This commit is contained in:
muava12 2026-02-25 10:27:58 +08:00
parent 9902eceec0
commit 78064da29a
4 changed files with 275 additions and 57 deletions

View file

@ -62,10 +62,13 @@ func cronHelp() {
fmt.Println(" -n, --name Job name") fmt.Println(" -n, --name Job name")
fmt.Println(" -m, --message Message for agent") fmt.Println(" -m, --message Message for agent")
fmt.Println(" -e, --every Run every N seconds") fmt.Println(" -e, --every Run every N seconds")
fmt.Println(" -a, --at Run once in N seconds from now")
fmt.Println(" -c, --cron Cron expression (e.g. '0 9 * * *')") fmt.Println(" -c, --cron Cron expression (e.g. '0 9 * * *')")
fmt.Println(" --command Shell command to execute")
fmt.Println(" -d, --deliver Deliver response to channel") fmt.Println(" -d, --deliver Deliver response to channel")
fmt.Println(" --to Recipient for delivery") fmt.Println(" --to Recipient for delivery")
fmt.Println(" --channel Channel for delivery") fmt.Println(" --channel Channel for delivery")
fmt.Println(" --delete-after Delete job after first run (default for --at)")
} }
func cronListCmd(storePath string) { func cronListCmd(storePath string) {
@ -111,8 +114,11 @@ func cronAddCmd(storePath string) {
name := "" name := ""
message := "" message := ""
var everySec *int64 var everySec *int64
var atSec *int64
cronExpr := "" cronExpr := ""
command := ""
deliver := false deliver := false
deleteAfter := false
channel := "" channel := ""
to := "" to := ""
@ -136,13 +142,27 @@ func cronAddCmd(storePath string) {
everySec = &sec everySec = &sec
i++ i++
} }
case "-a", "--at":
if i+1 < len(args) {
var sec int64
fmt.Sscanf(args[i+1], "%d", &sec)
atSec = &sec
i++
}
case "-c", "--cron": case "-c", "--cron":
if i+1 < len(args) { if i+1 < len(args) {
cronExpr = args[i+1] cronExpr = args[i+1]
i++ i++
} }
case "--command":
if i+1 < len(args) {
command = args[i+1]
i++
}
case "-d", "--deliver": case "-d", "--deliver":
deliver = true deliver = true
case "--delete-after":
deleteAfter = true
case "--to": case "--to":
if i+1 < len(args) { if i+1 < len(args) {
to = args[i+1] to = args[i+1]
@ -166,13 +186,20 @@ func cronAddCmd(storePath string) {
return return
} }
if everySec == nil && cronExpr == "" { if atSec == nil && everySec == nil && cronExpr == "" {
fmt.Println("Error: Either --every or --cron must be specified") fmt.Println("Error: One of --at, --every, or --cron must be specified")
return return
} }
var schedule cron.CronSchedule var schedule cron.CronSchedule
if everySec != nil { if atSec != nil {
atMS := time.Now().UnixMilli() + *atSec*1000
schedule = cron.CronSchedule{
Kind: "at",
AtMS: &atMS,
}
deleteAfter = true // at jobs always delete after run
} else if everySec != nil {
everyMS := *everySec * 1000 everyMS := *everySec * 1000
schedule = cron.CronSchedule{ schedule = cron.CronSchedule{
Kind: "every", Kind: "every",
@ -185,6 +212,11 @@ func cronAddCmd(storePath string) {
} }
} }
// If command is set, deliver should be false
if command != "" {
deliver = false
}
cs := cron.NewCronService(storePath, nil) cs := cron.NewCronService(storePath, nil)
job, err := cs.AddJob(name, schedule, message, deliver, channel, to) job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
if err != nil { if err != nil {
@ -192,6 +224,17 @@ func cronAddCmd(storePath string) {
return return
} }
// Set command and deleteAfterRun if needed
if command != "" {
job.Payload.Command = command
}
if deleteAfter {
job.DeleteAfterRun = true
}
if command != "" || deleteAfter {
cs.UpdateJob(job)
}
fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID) fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID)
} }

View file

@ -51,7 +51,7 @@ func (t *CronTool) Name() string {
// Description returns the tool description // Description returns the tool description
func (t *CronTool) Description() string { func (t *CronTool) Description() string {
return "Schedule reminders, tasks, or system commands. IMPORTANT: When user asks to be reminded or scheduled, you MUST call this tool. Use 'at_seconds' for one-time reminders (e.g., 'remind me in 10 minutes' → at_seconds=600). Use 'every_seconds' ONLY for recurring tasks (e.g., 'every 2 hours' → every_seconds=7200). Use 'cron_expr' for complex recurring schedules. Use 'command' to execute shell commands directly." return "Schedule reminders, tasks, or system commands. IMPORTANT: When user asks to be reminded or scheduled, you MUST call this tool. Do NOT use system crontab or 'crontab -e'; always use this built-in cron tool. Use 'at_seconds' for one-time reminders (e.g., 'remind me in 10 minutes' → at_seconds=600). Use 'every_seconds' ONLY for recurring tasks (e.g., 'every 2 hours' → every_seconds=7200). Use 'cron_expr' for complex recurring schedules. Use 'command' to execute shell commands directly."
} }
// Parameters returns the tool parameters schema // Parameters returns the tool parameters schema
@ -299,6 +299,11 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
ChatID: chatID, ChatID: chatID,
Content: output, Content: output,
}) })
// Reload store in case command modified cron/jobs.json directly
// (e.g., prayer auto_schedule writes AT jobs to the store file)
t.cronService.Load()
return "ok" return "ok"
} }

View file

@ -24,10 +24,10 @@ chmod +x skills/prayer-times/scripts/prayer_notify.sh
| `today` | Tampilkan jadwal hari ini | | `today` | Tampilkan jadwal hari ini |
| `schedule [prayers...]` | Output `nama\|HH:MM\|detik` untuk sholat yang belum lewat | | `schedule [prayers...]` | Output `nama\|HH:MM\|detik` untuk sholat yang belum lewat |
| `notify <prayer> <time>` | Kirim ke ntfy + cetak pesan untuk Telegram | | `notify <prayer> <time>` | Kirim ke ntfy + cetak pesan untuk Telegram |
| `auto_schedule <channel> <chat_id>` | **Tulis cron job via picoclaw CLI (tanpa AI)** |
| `status` | Tampilkan config dan status data | | `status` | Tampilkan config dan status data |
**Auto-fetch**: `schedule` dan `today` otomatis fetch jika data bulan ini belum ada. **Auto-fetch**: `schedule`, `today`, dan `auto_schedule` otomatis fetch jika data bulan ini belum ada.
Jadi meski device mati saat awal bulan, data tetap ter-update saat dipakai.
## Trigger Phrases ## Trigger Phrases
@ -47,7 +47,7 @@ Saat user **pertama kali** minta reminder sholat:
### 2. Setup kota (via exec tool, BUKAN cron) ### 2. Setup kota (via exec tool, BUKAN cron)
```bash ```bash
bash skills/prayer-times/scripts/prayer_notify.sh setup samarinda bash skills/prayer-times/scripts/prayer_notify.sh setup dumai
``` ```
Ini akan save config ke `skills/prayer-times/data/config` dan fetch data bulan ini. Ini akan save config ke `skills/prayer-times/data/config` dan fetch data bulan ini.
@ -63,21 +63,27 @@ Simpan juga ke MEMORY.md agar tidak lupa.
{"action": "add", "message": "Monthly prayer fetch", "command": "bash skills/prayer-times/scripts/prayer_notify.sh fetch", "cron_expr": "0 1 1 * *"} {"action": "add", "message": "Monthly prayer fetch", "command": "bash skills/prayer-times/scripts/prayer_notify.sh fetch", "cron_expr": "0 1 1 * *"}
``` ```
### 5. Setup daily scheduler (jam 01:30 setiap hari) ### 5. Setup daily auto-scheduler (jam 01:30 setiap hari)
**PENTING**: Gunakan `command` mode agar script langsung menulis cron job. AI **tidak** perlu terlibat.
```json ```json
{ {
"action": "add", "action": "add",
"message": "Baca jadwal sholat hari ini. Jalankan: bash skills/prayer-times/scripts/prayer_notify.sh schedule. Untuk setiap baris output (format: nama|waktu|detik), buat 2 cron job one-time: (1) deliver:true untuk Telegram dengan pesan emoji, (2) command dengan: bash skills/prayer-times/scripts/prayer_notify.sh notify <nama> <waktu> untuk ntfy.", "message": "Daily prayer auto-schedule",
"cron_expr": "30 1 * * *", "command": "bash skills/prayer-times/scripts/prayer_notify.sh auto_schedule telegram CHAT_ID",
"deliver": false "cron_expr": "30 1 * * *"
} }
``` ```
Ganti `CHAT_ID` dengan chat ID user yang meminta (tersedia di session context).
Script akan menambah reminder sholat ke cron via `picoclaw cron add`, tanpa perlu AI.
## Config File ## Config File
Disimpan di `skills/prayer-times/data/config`**milik skill ini sendiri, tidak shared**: Disimpan di `skills/prayer-times/data/config`**milik skill ini sendiri, tidak shared**:
``` ```
CITY="samarinda" CITY="dumai"
SAHUR_MINS="30" SAHUR_MINS="30"
IFTAR_MINS="10" IFTAR_MINS="10"
PRAYERS="shubuh dzuhur ashr magrib isya" PRAYERS="shubuh dzuhur ashr magrib isya"
@ -89,38 +95,17 @@ User bisa minta ubah via exec tool:
- Ganti kota: `prayer_notify.sh setup <kota_baru>` - Ganti kota: `prayer_notify.sh setup <kota_baru>`
- Ganti ntfy: edit NTFY_TOPIC di config - Ganti ntfy: edit NTFY_TOPIC di config
## Cara Agent Memproses Daily Scheduler ## Timezone
Saat daily scheduler trigger (jam 01:30), agent HARUS: Script otomatis mapping kota Indonesia ke timezone yang benar:
### Step 1: Jalankan script via exec tool | Zona | Contoh Kota | TZ |
```bash |------|-------------|-----|
bash skills/prayer-times/scripts/prayer_notify.sh schedule | WIB (UTC+7) | dumai, pekanbaru, jakarta, surabaya | Asia/Jakarta |
``` | WITA (UTC+8) | samarinda, makassar, denpasar | Asia/Makassar |
| WIT (UTC+9) | jayapura, ambon, manokwari | Asia/Jayapura |
Output contoh: Ini penting agar epoch calculation sesuai waktu lokal kota, bukan system timezone.
```
shubuh|05:06|12360
dzuhur|12:27|52020
ashr|15:42|63720
magrib|18:30|73800
isya|19:39|77940
```
### Step 2: Untuk SETIAP baris, buat 2 cron job
**Telegram** (deliver=true):
```json
{"action": "add", "message": "🌅 Waktu Shubuh (05:06) — Saatnya menunaikan sholat shubuh.", "at_seconds": 12360}
```
**ntfy** (command):
```json
{"action": "add", "message": "ntfy: shubuh", "command": "bash skills/prayer-times/scripts/prayer_notify.sh notify shubuh 05:06", "at_seconds": 12360}
```
### Step 3: Konfirmasi (satu pesan ringkasan)
Kirim ringkasan ke user: "✅ Reminder sholat hari ini sudah diset: Shubuh 05:06, Dzuhur 12:27, ..."
## Waktu yang Tersedia ## Waktu yang Tersedia
@ -138,8 +123,6 @@ Kirim ringkasan ke user: "✅ Reminder sholat hari ini sudah diset: Shubuh 05:06
1. **Tanya kota** saat pertama kali — jangan asumsi. Jalankan `setup <kota>` sebelum apapun. 1. **Tanya kota** saat pertama kali — jangan asumsi. Jalankan `setup <kota>` sebelum apapun.
2. **JANGAN hardcode waktu sholat** — selalu baca dari script output. Waktu berubah setiap hari. 2. **JANGAN hardcode waktu sholat** — selalu baca dari script output. Waktu berubah setiap hari.
3. **JANGAN buat cron_expr untuk waktu sholat** — karena waktu berubah harian, gunakan daily scheduler + at_seconds. 3. **Gunakan auto_schedule** untuk daily scheduler — ini menulis cron job langsung tanpa AI.
4. **Parse output dengan benar** — format: `nama|HH:MM|detik`. Kolom ke-3 = `at_seconds`. 4. **at_seconds auto-delete** — one-time job otomatis hilang setelah trigger.
5. **Dual delivery** — setiap reminder: Telegram (deliver:true) + ntfy (via notify command). 5. **Auto-fetch** — script otomatis download data jika file bulan ini belum ada.
6. **at_seconds auto-delete** — one-time job otomatis hilang setelah trigger.
7. **Auto-fetch** — script otomatis download data jika file bulan ini belum ada. Aman jika device mati saat awal bulan.

View file

@ -7,6 +7,8 @@
# prayer_notify.sh notify <prayer> <time> — Send notification via ntfy + stdout # prayer_notify.sh notify <prayer> <time> — Send notification via ntfy + stdout
# prayer_notify.sh setup <city> — Set city and fetch initial data # prayer_notify.sh setup <city> — Set city and fetch initial data
# prayer_notify.sh status — Show current config and data status # prayer_notify.sh status — Show current config and data status
# prayer_notify.sh auto_schedule <channel> <chat_id>
# — Add cron jobs via CLI (no AI)
# #
# Config file: skills/prayer-times/data/config (co-located with skill) # Config file: skills/prayer-times/data/config (co-located with skill)
# Auto-fetch: schedule command auto-fetches if data is missing or stale # Auto-fetch: schedule command auto-fetches if data is missing or stale
@ -49,6 +51,34 @@ EOF
echo "Config saved to $CONFIG_FILE" echo "Config saved to $CONFIG_FILE"
} }
# ---- Timezone mapping ----
# Map Indonesian city to IANA timezone.
# jadwalsholatorg data is in the city's local time,
# so we need the correct TZ for epoch calculation.
city_to_tz() {
local city="$1"
case "$city" in
# WIB (UTC+7) — Sumatra, Jawa, Kalimantan Barat/Tengah
dumai|pekanbaru|medan|padang|palembang|jambi|bengkulu|lampung|\
banda-aceh|batam|tanjung-pinang|pangkal-pinang|bandar-lampung|\
jakarta-pusat|jakarta-selatan|jakarta-barat|jakarta-timur|jakarta-utara|\
bogor|depok|tangerang|bekasi|bandung|semarang|yogyakarta|surabaya|\
malang|solo|cirebon|serang|pontianak|palangka-raya)
echo "Asia/Jakarta" ;;
# WITA (UTC+8) — Kalimantan Selatan/Timur/Utara, Sulawesi, Bali, NTB, NTT
samarinda|makassar|denpasar|balikpapan|banjarmasin|manado|gorontalo|\
palu|kendari|mamuju|mataram|kupang|tarakan|bontang)
echo "Asia/Makassar" ;;
# WIT (UTC+9) — Papua, Maluku
jayapura|ambon|manokwari|sorong|ternate|tual|merauke|fakfak)
echo "Asia/Jayapura" ;;
*)
# Default: try to infer from system TZ, fallback to WIB
echo "${TZ:-Asia/Jakarta}" ;;
esac
}
# ---- Helper functions ---- # ---- Helper functions ----
get_json_path() { get_json_path() {
@ -160,9 +190,13 @@ cmd_schedule() {
# Filter: use args if provided, otherwise use config # Filter: use args if provided, otherwise use config
local filter="${*:-$PRAYERS}" local filter="${*:-$PRAYERS}"
python3 -c " # Determine the correct timezone for this city
import json, sys, time local city_tz
from datetime import datetime city_tz=$(city_to_tz "$CITY")
TZ="$city_tz" python3 -c "
import json, sys, time, os
from datetime import datetime, timezone, timedelta
data = json.load(open('$json_path')) data = json.load(open('$json_path'))
today = '$today' today = '$today'
@ -258,6 +292,7 @@ cmd_notify() {
cmd_status() { cmd_status() {
echo "=== Prayer Times Config ===" echo "=== Prayer Times Config ==="
echo "Kota : $CITY" echo "Kota : $CITY"
echo "Timezone : $(city_to_tz "$CITY")"
echo "Sahur : $SAHUR_MINS menit sebelum Shubuh" echo "Sahur : $SAHUR_MINS menit sebelum Shubuh"
echo "Iftar : $IFTAR_MINS menit sebelum Maghrib" echo "Iftar : $IFTAR_MINS menit sebelum Maghrib"
echo "Prayers : $PRAYERS" echo "Prayers : $PRAYERS"
@ -284,6 +319,155 @@ cmd_status() {
fi fi
} }
# ---- Auto-schedule: add cron jobs via CLI, no AI needed ----
cmd_auto_schedule() {
local channel="${1:-}"
local chat_id="${2:-}"
if [ -z "$channel" ] || [ -z "$chat_id" ]; then
echo "Usage: prayer_notify.sh auto_schedule <channel> <chat_id>"
exit 1
fi
ensure_data
local json_path today now_ts city_tz
json_path=$(get_json_path)
today=$(today_date)
now_ts=$(now_epoch)
city_tz=$(city_to_tz "$CITY")
local filter="$PRAYERS"
# First, remove old prayer one-time jobs from previous auto_schedule runs
local existing_jobs
existing_jobs=$(picoclaw cron list 2>/dev/null || true)
echo "$existing_jobs" | grep -oP '🕌.*?\((\K[^)]+)' | while read -r job_id; do
picoclaw cron remove "$job_id" 2>/dev/null || true
done
# Use Python to calculate times and output picoclaw cron add commands
local schedule_output
schedule_output=$(TZ="$city_tz" python3 - "$json_path" "$today" "$now_ts" \
"$SAHUR_MINS" "$IFTAR_MINS" "$filter" <<'PYTHON_EOF'
import json, sys, time
from datetime import datetime
json_path, today, now_ts_s, sahur_mins_s, iftar_mins_s, filter_arg = sys.argv[1:7]
now_ts = int(now_ts_s)
sahur_mins = int(sahur_mins_s)
iftar_mins = int(iftar_mins_s)
data = json.load(open(json_path))
entry = None
for e in data:
if e['tanggal'] == today:
entry = e
break
if not entry:
print('ERROR', file=sys.stderr)
sys.exit(1)
def hhmm_to_epoch(hhmm):
h, m = map(int, hhmm.split(':'))
dt = datetime.strptime(today + f' {h:02d}:{m:02d}:00', '%Y-%m-%d %H:%M:%S')
return int(dt.timestamp())
schedule = []
shubuh_epoch = hhmm_to_epoch(entry['shubuh'])
sahur_epoch = shubuh_epoch - (sahur_mins * 60)
sahur_time = time.strftime('%H:%M', time.localtime(sahur_epoch))
schedule.append(('sahur', sahur_time, sahur_epoch))
for name in ['shubuh', 'dzuhur', 'ashr', 'magrib', 'isya']:
schedule.append((name, entry[name], hhmm_to_epoch(entry[name])))
magrib_epoch = hhmm_to_epoch(entry['magrib'])
iftar_epoch = magrib_epoch - (iftar_mins * 60)
iftar_time = time.strftime('%H:%M', time.localtime(iftar_epoch))
schedule.append(('iftar', iftar_time, iftar_epoch))
# Filter
wanted = set(f.strip().lower() for f in filter_arg.split())
schedule = [s for s in schedule if s[0] in wanted]
schedule.sort(key=lambda x: x[2])
# Output future prayers: name|display_time|seconds_from_now
for name, display_time, epoch in schedule:
diff = epoch - now_ts
if diff > 0:
print(f'{name}|{display_time}|{diff}')
PYTHON_EOF
)
if [ -z "$schedule_output" ]; then
echo " Semua waktu sholat hari ini sudah lewat."
exit 0
fi
# Emoji and title maps
declare -A emoji_map=(
[sahur]="🌙" [shubuh]="🌅" [dzuhur]="☀️"
[ashr]="🌤️" [magrib]="🌇" [isya]="🌃" [iftar]="🍽️"
)
declare -A title_map=(
[sahur]="Waktu Sahur" [shubuh]="Waktu Shubuh" [dzuhur]="Waktu Dzuhur"
[ashr]="Waktu Ashar" [magrib]="Waktu Maghrib" [isya]="Waktu Isya"
[iftar]="Persiapan Buka Puasa"
)
local count=0
local summary="🕌 Reminder sholat $today ($CITY) sudah dijadwalkan:"
while IFS='|' read -r name display_time at_seconds; do
local emoji="${emoji_map[$name]:-🕌}"
local title="${title_map[$name]:-Waktu Sholat}"
local msg
if [ "$name" = "sahur" ]; then
msg="$emoji $title ($display_time) — Ayo bangun sahur! $SAHUR_MINS menit lagi waktu Imsyak."
elif [ "$name" = "iftar" ]; then
msg="$emoji $title ($display_time) — $IFTAR_MINS menit lagi waktu berbuka puasa!"
else
msg="$emoji $title ($display_time) — Saatnya menunaikan sholat $name."
fi
# Add Telegram delivery job
picoclaw cron add \
-n "🕌 $name $display_time" \
-m "$msg" \
--at "$at_seconds" \
-d \
--channel "$channel" \
--to "$chat_id" 2>/dev/null
# Add ntfy job if configured
if [ -n "$NTFY_TOPIC" ]; then
picoclaw cron add \
-n "🕌 ntfy:$name" \
-m "ntfy: $name" \
--at "$at_seconds" \
--command "bash $SCRIPT_DIR/prayer_notify.sh notify $name $display_time" \
--channel "$channel" \
--to "$chat_id" 2>/dev/null
fi
summary="$summary
$emoji ${title} ${display_time}"
count=$((count + 1))
done <<< "$schedule_output"
local ntfy_status="❌ tidak aktif"
[ -n "$NTFY_TOPIC" ] && ntfy_status="✅ aktif"
echo "$summary"
echo ""
echo "📍 Timezone: $(city_to_tz "$CITY")"
echo "📊 Total: $count reminder | ntfy: $ntfy_status"
}
# ---- Main ---- # ---- Main ----
load_config load_config
@ -295,8 +479,9 @@ case "${1:-help}" in
schedule) shift; cmd_schedule "$@" ;; schedule) shift; cmd_schedule "$@" ;;
notify) shift; cmd_notify "$@" ;; notify) shift; cmd_notify "$@" ;;
status) cmd_status ;; status) cmd_status ;;
auto_schedule) shift; cmd_auto_schedule "$@" ;;
help|*) help|*)
echo "Usage: prayer_notify.sh {setup|fetch|today|schedule|notify|status}" echo "Usage: prayer_notify.sh {setup|fetch|today|schedule|notify|status|auto_schedule}"
echo "" echo ""
echo "Commands:" echo "Commands:"
echo " setup <city> Set kota dan fetch data awal" echo " setup <city> Set kota dan fetch data awal"
@ -305,6 +490,8 @@ case "${1:-help}" in
echo " schedule [prayers...] Hitung detik-dari-sekarang untuk reminder" echo " schedule [prayers...] Hitung detik-dari-sekarang untuk reminder"
echo " notify <prayer> <time> Kirim notifikasi (ntfy + stdout)" echo " notify <prayer> <time> Kirim notifikasi (ntfy + stdout)"
echo " status Tampilkan config dan status data" echo " status Tampilkan config dan status data"
echo " auto_schedule <store> <channel> <chat_id>"
echo " Tulis cron job langsung (tanpa AI)"
echo "" echo ""
echo "Config: $CONFIG_FILE" echo "Config: $CONFIG_FILE"
echo "Kota saat ini: $CITY" echo "Kota saat ini: $CITY"