feat: add skills, picoclaw manager, and improve fallback errors
- add reminder skill with dual delivery (telegram + ntfy) - add prayer-times skill with daily scheduler and auto-fetch - add picoclaw manager API server and systemd setup script - show human-readable cooldown duration in fallback error messages ntfy topic is configurable via PRAYER_NTFY_TOPIC env var. sensitive URLs are not hardcoded in skill files.
This commit is contained in:
parent
29b6e486ff
commit
5742374ce7
8 changed files with 1429 additions and 4 deletions
35
PICOCLAW_MANAGER_API.md
Normal file
35
PICOCLAW_MANAGER_API.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# PicoClaw Manager API
|
||||
|
||||
HTTP API server untuk mengontrol lifecycle PicoClaw gateway process.
|
||||
|
||||
Base URL: `http://{host}:{port}` (default: `http://localhost:8321`)
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `GET /api/health`
|
||||
Health check server. Tidak perlu auth.
|
||||
- Response: `{"status": "ok", "service": "picoclaw-manager", "timestamp": "..."}`
|
||||
|
||||
### `GET /api/picoclaw/status`
|
||||
Cek status PicoClaw gateway: apakah running, PID, uptime, dan 20 baris log terakhir.
|
||||
- Response: `{"running": true, "pid": 1234, "started_at": "...", "uptime_seconds": 3600, "recent_logs": [...]}`
|
||||
|
||||
### `POST /api/picoclaw/start`
|
||||
Jalankan PicoClaw gateway. Gagal jika sudah berjalan.
|
||||
- Response sukses: `{"success": true, "message": "...", "pid": 1234}`
|
||||
- Response gagal: `{"success": false, "message": "PicoClaw gateway sudah berjalan"}`
|
||||
|
||||
### `POST /api/picoclaw/stop`
|
||||
Hentikan PicoClaw gateway (SIGTERM → SIGKILL fallback).
|
||||
- Response: `{"success": true, "message": "PicoClaw gateway berhasil dihentikan (PID: 1234)"}`
|
||||
|
||||
### `POST /api/picoclaw/restart`
|
||||
Stop lalu start ulang PicoClaw gateway. Bisa dipanggil meskipun gateway sedang tidak berjalan.
|
||||
- Response: `{"success": true, "message": "...", "pid": 5678}`
|
||||
|
||||
## Contoh Request
|
||||
```bash
|
||||
curl http://localhost:8321/api/picoclaw/status
|
||||
curl -X POST http://localhost:8321/api/picoclaw/start
|
||||
curl -X POST http://localhost:8321/api/picoclaw/restart
|
||||
```
|
||||
396
picoclaw_manager.py
Normal file
396
picoclaw_manager.py
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
PicoClaw Manager Server
|
||||
──────────────────────────
|
||||
Lightweight HTTP API to manage PicoClaw process lifecycle.
|
||||
|
||||
Endpoints:
|
||||
POST /api/picoclaw/restart → Kill & restart PicoClaw gateway
|
||||
POST /api/picoclaw/start → Start PicoClaw gateway
|
||||
POST /api/picoclaw/stop → Stop PicoClaw gateway
|
||||
GET /api/picoclaw/status → Check if PicoClaw gateway is running
|
||||
GET /api/health → Health check
|
||||
|
||||
Usage:
|
||||
python3 picoclaw_manager.py # default port 8321
|
||||
python3 picoclaw_manager.py --port 9000 # custom port
|
||||
python3 picoclaw_manager.py --token mysecretkey # with auth token
|
||||
python3 picoclaw_manager.py --picoclaw-bin /path/bin # custom binary path
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from threading import Thread, Lock
|
||||
from datetime import datetime
|
||||
|
||||
# ── Config ────────────────────────────────────────
|
||||
DEFAULT_PORT = 8321
|
||||
DEFAULT_PICOCLAW_BIN = os.path.expanduser("~/.local/bin/picoclaw")
|
||||
DEFAULT_CONFIG_PATH = os.path.expanduser("~/.picoclaw/config.json")
|
||||
|
||||
# ── Logging ───────────────────────────────────────
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s │ %(levelname)-7s │ %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
log = logging.getLogger("picoclaw-manager")
|
||||
|
||||
|
||||
class PicoClawManager:
|
||||
"""Manages the PicoClaw gateway process lifecycle."""
|
||||
|
||||
def __init__(self, picoclaw_bin: str, config_path: str):
|
||||
self.picoclaw_bin = picoclaw_bin
|
||||
self.config_path = config_path
|
||||
self._process = None
|
||||
self._lock = Lock()
|
||||
self._started_at = None
|
||||
self._log_tail = []
|
||||
self._max_log_lines = 100
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
with self._lock:
|
||||
if self._process is None:
|
||||
return False
|
||||
return self._process.poll() is None
|
||||
|
||||
def status(self) -> dict:
|
||||
running = self.is_running
|
||||
info = {
|
||||
"running": running,
|
||||
"pid": self._process.pid if self._process and running else None,
|
||||
"started_at": self._started_at,
|
||||
"uptime_seconds": None,
|
||||
"binary": self.picoclaw_bin,
|
||||
"recent_logs": self._log_tail[-20:],
|
||||
}
|
||||
if running and self._started_at:
|
||||
delta = datetime.now() - datetime.fromisoformat(self._started_at)
|
||||
info["uptime_seconds"] = int(delta.total_seconds())
|
||||
return info
|
||||
|
||||
def start(self) -> dict:
|
||||
with self._lock:
|
||||
if self._process and self._process.poll() is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "PicoClaw gateway sudah berjalan",
|
||||
"pid": self._process.pid,
|
||||
}
|
||||
|
||||
return self._start_process()
|
||||
|
||||
def stop(self) -> dict:
|
||||
with self._lock:
|
||||
if self._process is None or self._process.poll() is not None:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "PicoClaw gateway tidak sedang berjalan",
|
||||
}
|
||||
|
||||
return self._stop_process()
|
||||
|
||||
def restart(self) -> dict:
|
||||
with self._lock:
|
||||
# Stop if running
|
||||
if self._process and self._process.poll() is None:
|
||||
self._stop_process()
|
||||
time.sleep(1) # brief cooldown
|
||||
|
||||
return self._start_process()
|
||||
|
||||
def _start_process(self) -> dict:
|
||||
"""Internal: start the picoclaw gateway (must hold lock)."""
|
||||
if not os.path.isfile(self.picoclaw_bin):
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Binary tidak ditemukan: {self.picoclaw_bin}",
|
||||
}
|
||||
|
||||
cmd = [self.picoclaw_bin, "gateway"]
|
||||
|
||||
env = os.environ.copy()
|
||||
# Load .env file if exists alongside the binary or in cwd
|
||||
for env_path in [".env", os.path.join(os.path.dirname(self.picoclaw_bin), ".env")]:
|
||||
if os.path.isfile(env_path):
|
||||
self._load_env_file(env_path, env)
|
||||
log.info("Loaded env from: %s", env_path)
|
||||
break
|
||||
|
||||
try:
|
||||
self._log_tail.clear()
|
||||
self._process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
env=env,
|
||||
preexec_fn=os.setsid, # new process group for clean kill
|
||||
)
|
||||
self._started_at = datetime.now().isoformat()
|
||||
|
||||
# Background thread to capture logs
|
||||
log_thread = Thread(
|
||||
target=self._read_output,
|
||||
args=(self._process,),
|
||||
daemon=True,
|
||||
)
|
||||
log_thread.start()
|
||||
|
||||
log.info(
|
||||
"✓ PicoClaw gateway started (PID: %d)", self._process.pid
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "PicoClaw gateway berhasil dijalankan",
|
||||
"pid": self._process.pid,
|
||||
}
|
||||
except Exception as e:
|
||||
log.error("✗ Gagal menjalankan PicoClaw: %s", e)
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Gagal menjalankan PicoClaw: {str(e)}",
|
||||
}
|
||||
|
||||
def _stop_process(self) -> dict:
|
||||
"""Internal: stop the running process (must hold lock)."""
|
||||
pid = self._process.pid
|
||||
try:
|
||||
# Send SIGTERM to the entire process group
|
||||
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
||||
# Wait up to 5 seconds for graceful shutdown
|
||||
for _ in range(50):
|
||||
if self._process.poll() is not None:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
# Force kill if still alive
|
||||
os.killpg(os.getpgid(pid), signal.SIGKILL)
|
||||
self._process.wait(timeout=3)
|
||||
|
||||
log.info("✓ PicoClaw gateway stopped (PID: %d)", pid)
|
||||
self._process = None
|
||||
self._started_at = None
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"PicoClaw gateway berhasil dihentikan (PID: {pid})",
|
||||
}
|
||||
except Exception as e:
|
||||
log.error("✗ Gagal menghentikan PicoClaw: %s", e)
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Gagal menghentikan process: {str(e)}",
|
||||
}
|
||||
|
||||
def _read_output(self, process: subprocess.Popen):
|
||||
"""Capture process stdout in background."""
|
||||
try:
|
||||
for line in iter(process.stdout.readline, b""):
|
||||
decoded = line.decode("utf-8", errors="replace").rstrip()
|
||||
self._log_tail.append(decoded)
|
||||
if len(self._log_tail) > self._max_log_lines:
|
||||
self._log_tail.pop(0)
|
||||
log.info("[picoclaw] %s", decoded)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _load_env_file(path: str, env: dict):
|
||||
"""Parse a simple .env file into env dict."""
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" in line:
|
||||
key, _, value = line.partition("=")
|
||||
env[key.strip()] = value.strip()
|
||||
|
||||
|
||||
# ── HTTP Handler ──────────────────────────────────
|
||||
|
||||
class PicoClawHandler(BaseHTTPRequestHandler):
|
||||
"""REST API handler for PicoClaw management."""
|
||||
|
||||
manager: PicoClawManager = None
|
||||
auth_token: str = None
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/api/health":
|
||||
self._json_response(200, {
|
||||
"status": "ok",
|
||||
"service": "picoclaw-manager",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
})
|
||||
elif self.path == "/api/picoclaw/status":
|
||||
if not self._check_auth():
|
||||
return
|
||||
self._json_response(200, self.manager.status())
|
||||
else:
|
||||
self._json_response(404, {"error": "Not found"})
|
||||
|
||||
def do_POST(self):
|
||||
if not self._check_auth():
|
||||
return
|
||||
|
||||
routes = {
|
||||
"/api/picoclaw/start": self.manager.start,
|
||||
"/api/picoclaw/stop": self.manager.stop,
|
||||
"/api/picoclaw/restart": self.manager.restart,
|
||||
}
|
||||
|
||||
handler = routes.get(self.path)
|
||||
if handler:
|
||||
result = handler()
|
||||
code = 200 if result.get("success", True) else 500
|
||||
self._json_response(code, result)
|
||||
else:
|
||||
self._json_response(404, {"error": "Not found"})
|
||||
|
||||
def _check_auth(self) -> bool:
|
||||
"""Validate Bearer token if auth is configured."""
|
||||
if not self.auth_token:
|
||||
return True
|
||||
|
||||
auth_header = self.headers.get("Authorization", "")
|
||||
if auth_header == f"Bearer {self.auth_token}":
|
||||
return True
|
||||
|
||||
self._json_response(401, {"error": "Unauthorized"})
|
||||
return False
|
||||
|
||||
def _json_response(self, code: int, data: dict):
|
||||
body = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_OPTIONS(self):
|
||||
"""Handle CORS preflight."""
|
||||
self.send_response(204)
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""Route HTTP logs through our logger."""
|
||||
log.debug("%s %s", self.client_address[0], format % args)
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="PicoClaw Manager — Process Lifecycle Server",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Contoh penggunaan:
|
||||
python3 picoclaw_manager.py
|
||||
python3 picoclaw_manager.py --port 9000 --token rahasia123
|
||||
|
||||
Contoh request (curl):
|
||||
curl http://localhost:8321/api/picoclaw/status
|
||||
curl -X POST http://localhost:8321/api/picoclaw/start
|
||||
curl -X POST http://localhost:8321/api/picoclaw/stop
|
||||
curl -X POST http://localhost:8321/api/picoclaw/restart
|
||||
|
||||
Dengan auth token:
|
||||
curl -H "Authorization: Bearer rahasia123" http://localhost:8321/api/picoclaw/status
|
||||
""",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=DEFAULT_PORT,
|
||||
help=f"Port untuk API server (default: {DEFAULT_PORT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host", default="0.0.0.0",
|
||||
help="Bind address (default: 0.0.0.0)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--token",
|
||||
default=os.environ.get("PICOCLAW_MANAGER_TOKEN"),
|
||||
help="Bearer token untuk autentikasi (opsional, bisa via env PICOCLAW_API_TOKEN)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--picoclaw-bin", default=DEFAULT_PICOCLAW_BIN,
|
||||
help=f"Path ke binary picoclaw (default: {DEFAULT_PICOCLAW_BIN})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config", default=DEFAULT_CONFIG_PATH,
|
||||
help=f"Path ke config.json (default: {DEFAULT_CONFIG_PATH})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--auto-start", action="store_true",
|
||||
help="Otomatis start PicoClaw gateway saat server dimulai",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Wire up the manager
|
||||
manager = PicoClawManager(args.picoclaw_bin, args.config)
|
||||
PicoClawHandler.manager = manager
|
||||
PicoClawHandler.auth_token = args.token
|
||||
|
||||
server = HTTPServer((args.host, args.port), PicoClawHandler)
|
||||
|
||||
# Banner
|
||||
print()
|
||||
print(" ┌─────────────────────────────────────────┐")
|
||||
print(" │ 🦀 PicoClaw Manager Server │")
|
||||
print(" └─────────────────────────────────────────┘")
|
||||
print()
|
||||
print(f" Listening → http://{args.host}:{args.port}")
|
||||
print(f" Binary → {args.picoclaw_bin}")
|
||||
print(f" Auth → {'✓ enabled' if args.token else '✗ disabled'}")
|
||||
print()
|
||||
print(" Endpoints:")
|
||||
print(" GET /api/health → Health check")
|
||||
print(" GET /api/picoclaw/status → Status PicoClaw")
|
||||
print(" POST /api/picoclaw/start → Start gateway")
|
||||
print(" POST /api/picoclaw/stop → Stop gateway")
|
||||
print(" POST /api/picoclaw/restart → Restart gateway")
|
||||
print()
|
||||
|
||||
if args.auto_start:
|
||||
log.info("Auto-starting PicoClaw gateway...")
|
||||
result = manager.start()
|
||||
log.info("Auto-start: %s", result["message"])
|
||||
|
||||
# Graceful shutdown
|
||||
def shutdown_handler(signum, frame):
|
||||
log.info("Shutting down...")
|
||||
# Run cleanup in a separate thread to avoid deadlock
|
||||
# (signal handler runs in main thread, same as serve_forever)
|
||||
def _cleanup():
|
||||
if manager.is_running:
|
||||
manager.stop()
|
||||
server.shutdown()
|
||||
Thread(target=_cleanup, daemon=True).start()
|
||||
|
||||
signal.signal(signal.SIGINT, shutdown_handler)
|
||||
signal.signal(signal.SIGTERM, shutdown_handler)
|
||||
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
log.info("Server stopped.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -7,6 +7,26 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
// humanDuration formats a time.Duration into a concise human-readable string.
|
||||
// Examples: "45s", "4m32s", "1h5m", "2h0m".
|
||||
func humanDuration(d time.Duration) string {
|
||||
d = d.Round(time.Second)
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||
}
|
||||
if d < time.Hour {
|
||||
m := int(d.Minutes())
|
||||
s := int(d.Seconds()) % 60
|
||||
if s == 0 {
|
||||
return fmt.Sprintf("%dm", m)
|
||||
}
|
||||
return fmt.Sprintf("%dm%ds", m, s)
|
||||
}
|
||||
h := int(d.Hours())
|
||||
m := int(d.Minutes()) % 60
|
||||
return fmt.Sprintf("%dh%dm", h, m)
|
||||
}
|
||||
|
||||
// FallbackChain orchestrates model fallback across multiple candidates.
|
||||
type FallbackChain struct {
|
||||
cooldown *CooldownTracker
|
||||
|
|
@ -111,9 +131,8 @@ func (fc *FallbackChain) Execute(
|
|||
Skipped: true,
|
||||
Reason: FailoverRateLimit,
|
||||
Error: fmt.Errorf(
|
||||
"provider %s in cooldown (%s remaining)",
|
||||
candidate.Provider,
|
||||
remaining.Round(time.Second),
|
||||
"skipped (cooldown %s remaining)",
|
||||
humanDuration(remaining),
|
||||
),
|
||||
})
|
||||
continue
|
||||
|
|
@ -277,7 +296,11 @@ func (e *FallbackExhaustedError) Error() string {
|
|||
sb.WriteString(fmt.Sprintf("fallback: all %d candidates failed:", len(e.Attempts)))
|
||||
for i, a := range e.Attempts {
|
||||
if a.Skipped {
|
||||
sb.WriteString(fmt.Sprintf("\n [%d] %s/%s: skipped (cooldown)", i+1, a.Provider, a.Model))
|
||||
if a.Error != nil {
|
||||
sb.WriteString(fmt.Sprintf("\n [%d] %s/%s: %v", i+1, a.Provider, a.Model, a.Error))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("\n [%d] %s/%s: skipped (cooldown)", i+1, a.Provider, a.Model))
|
||||
}
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("\n [%d] %s/%s: %v (reason=%s, %s)",
|
||||
i+1, a.Provider, a.Model, a.Error, a.Reason, a.Duration.Round(time.Millisecond)))
|
||||
|
|
|
|||
237
setup_picoclaw_manager.sh
Normal file
237
setup_picoclaw_manager.sh
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
#!/bin/bash
|
||||
# ═══════════════════════════════════════════════════
|
||||
# PicoClaw Manager — Installer & Service Manager
|
||||
# Untuk Armbian / Debian / Ubuntu
|
||||
# ═══════════════════════════════════════════════════
|
||||
set -e
|
||||
|
||||
SERVICE_NAME="picoclaw-manager"
|
||||
INSTALL_DIR="/opt/picoclaw"
|
||||
SCRIPT_NAME="picoclaw_manager.py"
|
||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
PICOCLAW_BIN="$HOME/.local/bin/picoclaw"
|
||||
RUN_USER="$(whoami)"
|
||||
|
||||
# ── Warna ─────────────────────────────────────────
|
||||
R='\033[0;31m' G='\033[0;32m' B='\033[0;34m'
|
||||
Y='\033[1;33m' C='\033[0;36m' W='\033[1;37m' X='\033[0m'
|
||||
|
||||
banner() {
|
||||
echo ""
|
||||
echo -e " ${C}┌──────────────────────────────────────┐${X}"
|
||||
echo -e " ${C}│${W} 🦀 PicoClaw Service Manager ${C}│${X}"
|
||||
echo -e " ${C}└──────────────────────────────────────┘${X}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
info() { echo -e " ${B}▸${X} $1"; }
|
||||
success() { echo -e " ${G}✓${X} $1"; }
|
||||
warn() { echo -e " ${Y}!${X} $1"; }
|
||||
err() { echo -e " ${R}✗${X} $1"; }
|
||||
|
||||
# ── Install ───────────────────────────────────────
|
||||
cmd_install() {
|
||||
banner
|
||||
info "Installing ${SERVICE_NAME}..."
|
||||
echo ""
|
||||
|
||||
# Cari picoclaw_api.py di folder yang sama dengan script ini
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
SOURCE="${SCRIPT_DIR}/${SCRIPT_NAME}"
|
||||
|
||||
if [ ! -f "$SOURCE" ]; then
|
||||
err "${SCRIPT_NAME} tidak ditemukan di ${SCRIPT_DIR}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copy script
|
||||
info "Copying ${SCRIPT_NAME} → ${INSTALL_DIR}/"
|
||||
sudo mkdir -p "$INSTALL_DIR"
|
||||
sudo cp "$SOURCE" "${INSTALL_DIR}/${SCRIPT_NAME}"
|
||||
sudo chmod +x "${INSTALL_DIR}/${SCRIPT_NAME}"
|
||||
success "Script copied"
|
||||
|
||||
# Copy .env jika ada
|
||||
if [ -f "${SCRIPT_DIR}/.env" ]; then
|
||||
sudo cp "${SCRIPT_DIR}/.env" "${INSTALL_DIR}/.env"
|
||||
success ".env copied"
|
||||
fi
|
||||
|
||||
# Buat systemd service
|
||||
info "Creating systemd service..."
|
||||
sudo tee "$SERVICE_FILE" > /dev/null << UNIT
|
||||
[Unit]
|
||||
Description=PicoClaw Manager — Process Lifecycle Server
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${RUN_USER}
|
||||
WorkingDirectory=${INSTALL_DIR}
|
||||
ExecStart=/usr/bin/python3 ${INSTALL_DIR}/${SCRIPT_NAME} --auto-start --picoclaw-bin ${PICOCLAW_BIN}
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
success "Service file created: ${SERVICE_FILE}"
|
||||
|
||||
# Reload & enable
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable "$SERVICE_NAME"
|
||||
success "Service enabled (auto-start on boot)"
|
||||
|
||||
# Start
|
||||
sudo systemctl start "$SERVICE_NAME"
|
||||
success "Service started"
|
||||
|
||||
echo ""
|
||||
info "Cek status: ${W}$0 status${X}"
|
||||
info "Lihat log: ${W}$0 logs${X}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ── Uninstall ─────────────────────────────────────
|
||||
cmd_uninstall() {
|
||||
banner
|
||||
warn "Uninstalling ${SERVICE_NAME}..."
|
||||
echo ""
|
||||
|
||||
if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
|
||||
sudo systemctl stop "$SERVICE_NAME"
|
||||
success "Service stopped"
|
||||
fi
|
||||
|
||||
if [ -f "$SERVICE_FILE" ]; then
|
||||
sudo systemctl disable "$SERVICE_NAME" 2>/dev/null || true
|
||||
sudo rm -f "$SERVICE_FILE"
|
||||
sudo systemctl daemon-reload
|
||||
success "Service file removed"
|
||||
fi
|
||||
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
read -p " Hapus ${INSTALL_DIR}? [y/N] " -n 1 -r
|
||||
echo ""
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
sudo rm -rf "$INSTALL_DIR"
|
||||
success "Install directory removed"
|
||||
fi
|
||||
fi
|
||||
|
||||
success "Uninstall selesai"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ── Update ────────────────────────────────────────
|
||||
cmd_update() {
|
||||
banner
|
||||
info "Updating ${SCRIPT_NAME}..."
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
SOURCE="${SCRIPT_DIR}/${SCRIPT_NAME}"
|
||||
|
||||
if [ ! -f "$SOURCE" ]; then
|
||||
err "${SCRIPT_NAME} tidak ditemukan di ${SCRIPT_DIR}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sudo cp "$SOURCE" "${INSTALL_DIR}/${SCRIPT_NAME}"
|
||||
sudo chmod +x "${INSTALL_DIR}/${SCRIPT_NAME}"
|
||||
success "Script updated"
|
||||
|
||||
sudo systemctl restart "$SERVICE_NAME"
|
||||
success "Service restarted"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ── Service Commands ──────────────────────────────
|
||||
cmd_start() { sudo systemctl start "$SERVICE_NAME" && success "Started"; }
|
||||
cmd_stop() { sudo systemctl stop "$SERVICE_NAME" && success "Stopped"; }
|
||||
cmd_restart() { sudo systemctl restart "$SERVICE_NAME" && success "Restarted"; }
|
||||
|
||||
cmd_status() {
|
||||
banner
|
||||
echo -e " ${W}Systemd Status:${X}"
|
||||
echo ""
|
||||
sudo systemctl status "$SERVICE_NAME" --no-pager -l 2>/dev/null || warn "Service not found"
|
||||
echo ""
|
||||
|
||||
# Cek API health
|
||||
if command -v curl &> /dev/null; then
|
||||
echo -e " ${W}API Health Check:${X}"
|
||||
echo ""
|
||||
RESPONSE=$(curl -s http://localhost:8321/api/health 2>/dev/null || echo "unreachable")
|
||||
if echo "$RESPONSE" | grep -q '"ok"'; then
|
||||
success "API is responding: ${RESPONSE}"
|
||||
else
|
||||
warn "API not responding"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo -e " ${W}PicoClaw Gateway:${X}"
|
||||
echo ""
|
||||
GW_STATUS=$(curl -s http://localhost:8321/api/picoclaw/status 2>/dev/null || echo "{}")
|
||||
RUNNING=$(echo "$GW_STATUS" | grep -o '"running": *[a-z]*' | head -1 | awk '{print $2}')
|
||||
PID=$(echo "$GW_STATUS" | grep -o '"pid": *[0-9]*' | head -1 | awk '{print $2}')
|
||||
if [ "$RUNNING" = "true" ]; then
|
||||
success "Gateway running (PID: ${PID})"
|
||||
else
|
||||
warn "Gateway not running"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_logs() {
|
||||
journalctl -u "$SERVICE_NAME" -f --no-pager
|
||||
}
|
||||
|
||||
cmd_logs_history() {
|
||||
journalctl -u "$SERVICE_NAME" --no-pager -n "${1:-50}"
|
||||
}
|
||||
|
||||
# ── Usage ─────────────────────────────────────────
|
||||
cmd_help() {
|
||||
banner
|
||||
echo -e " ${W}Usage:${X} $0 <command>"
|
||||
echo ""
|
||||
echo -e " ${C}Setup${X}"
|
||||
echo " install Install & enable service"
|
||||
echo " uninstall Remove service & files"
|
||||
echo " update Update script & restart"
|
||||
echo ""
|
||||
echo -e " ${C}Service${X}"
|
||||
echo " start Start the API server"
|
||||
echo " stop Stop the API server"
|
||||
echo " restart Restart the API server"
|
||||
echo " status Show status & health check"
|
||||
echo ""
|
||||
echo -e " ${C}Logs${X}"
|
||||
echo " logs Follow live logs"
|
||||
echo " logs-history Show last 50 log lines"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ── Dispatch ──────────────────────────────────────
|
||||
case "${1:-help}" in
|
||||
install) cmd_install ;;
|
||||
uninstall) cmd_uninstall ;;
|
||||
update) cmd_update ;;
|
||||
start) cmd_start ;;
|
||||
stop) cmd_stop ;;
|
||||
restart) cmd_restart ;;
|
||||
status) cmd_status ;;
|
||||
logs) cmd_logs ;;
|
||||
logs-history) cmd_logs_history "$2" ;;
|
||||
help|--help|-h) cmd_help ;;
|
||||
*)
|
||||
err "Unknown command: $1"
|
||||
cmd_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
145
workspace/skills/prayer-times/SKILL.md
Normal file
145
workspace/skills/prayer-times/SKILL.md
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
---
|
||||
name: prayer-times
|
||||
description: Jadwal sholat otomatis — reminder waktu sholat, sahur, dan buka puasa. Data dari jadwalsholatorg.
|
||||
metadata: {"nanobot":{"emoji":"🕌"}}
|
||||
---
|
||||
|
||||
# Prayer Times / Jadwal Sholat
|
||||
|
||||
Reminder otomatis waktu sholat. Data dari [jadwalsholatorg](https://github.com/lakuapik/jadwalsholatorg).
|
||||
Notifikasi dikirim ke **Telegram + ntfy**.
|
||||
|
||||
## Script
|
||||
|
||||
Lokasi: `skills/prayer-times/scripts/prayer_notify.sh`
|
||||
|
||||
```bash
|
||||
chmod +x skills/prayer-times/scripts/prayer_notify.sh
|
||||
```
|
||||
|
||||
| Command | Fungsi |
|
||||
|---------|--------|
|
||||
| `setup <city>` | Set kota + fetch data awal **(wajib pertama kali)** |
|
||||
| `fetch` | Download JSON bulan ini |
|
||||
| `today` | Tampilkan jadwal hari ini |
|
||||
| `schedule [prayers...]` | Output `nama\|HH:MM\|detik` untuk sholat yang belum lewat |
|
||||
| `notify <prayer> <time>` | Kirim ke ntfy + cetak pesan untuk Telegram |
|
||||
| `status` | Tampilkan config dan status data |
|
||||
|
||||
**Auto-fetch**: `schedule` dan `today` otomatis fetch jika data bulan ini belum ada.
|
||||
Jadi meski device mati saat awal bulan, data tetap ter-update saat dipakai.
|
||||
|
||||
## Trigger Phrases
|
||||
|
||||
- "jadwal sholat" / "waktu sholat" / "prayer times"
|
||||
- "reminder sholat" / "aktifkan adzan"
|
||||
- "reminder sahur" / "bangun sahur"
|
||||
- "reminder buka puasa" / "iftar"
|
||||
- "stop/nonaktifkan reminder sholat"
|
||||
|
||||
## One-Time Setup
|
||||
|
||||
Saat user **pertama kali** minta reminder sholat:
|
||||
|
||||
### 1. Tanya kota dan ntfy topic
|
||||
- Kota: samarinda, dumai, pekanbaru, jakarta-pusat, surabaya, dll
|
||||
- ntfy topic: URL ntfy.sh user (opsional, bisa ditambah nanti)
|
||||
|
||||
### 2. Setup kota (via exec tool, BUKAN cron)
|
||||
```bash
|
||||
bash skills/prayer-times/scripts/prayer_notify.sh setup samarinda
|
||||
```
|
||||
Ini akan save config ke `~/.picoclaw/prayer-times/config` dan fetch data bulan ini.
|
||||
|
||||
### 3. Setup ntfy (opsional, via exec tool)
|
||||
Jika user memberikan ntfy URL, tambahkan ke config:
|
||||
```bash
|
||||
sed -i 's|NTFY_TOPIC=".*"|NTFY_TOPIC="https://ntfy.sh/USER_TOPIC"|' ~/.picoclaw/prayer-times/config
|
||||
```
|
||||
Simpan juga ke MEMORY.md agar tidak lupa.
|
||||
|
||||
### 4. Setup monthly fetch cron (tanggal 1, jam 01:00)
|
||||
```json
|
||||
{"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)
|
||||
```json
|
||||
{
|
||||
"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.",
|
||||
"cron_expr": "30 1 * * *",
|
||||
"deliver": false
|
||||
}
|
||||
```
|
||||
|
||||
## Config File
|
||||
|
||||
Disimpan di `~/.picoclaw/prayer-times/config` — **milik skill ini sendiri, tidak shared**:
|
||||
```
|
||||
CITY="samarinda"
|
||||
SAHUR_MINS="30"
|
||||
IFTAR_MINS="10"
|
||||
PRAYERS="shubuh dzuhur ashr magrib isya"
|
||||
NTFY_TOPIC="https://ntfy.sh/user-topic-here"
|
||||
```
|
||||
|
||||
User bisa minta ubah via exec tool:
|
||||
- Tambah sahur+iftar: edit PRAYERS di config lalu restart daily scheduler
|
||||
- Ganti kota: `prayer_notify.sh setup <kota_baru>`
|
||||
- Ganti ntfy: edit NTFY_TOPIC di config
|
||||
|
||||
## Cara Agent Memproses Daily Scheduler
|
||||
|
||||
Saat daily scheduler trigger (jam 01:30), agent HARUS:
|
||||
|
||||
### Step 1: Jalankan script via exec tool
|
||||
```bash
|
||||
bash skills/prayer-times/scripts/prayer_notify.sh schedule
|
||||
```
|
||||
|
||||
Output contoh:
|
||||
```
|
||||
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
|
||||
|
||||
| Nama | Keterangan | Perlu diminta user? |
|
||||
|------|------------|---------------------|
|
||||
| `shubuh` | Waktu Shubuh | Default aktif |
|
||||
| `dzuhur` | Waktu Dzuhur | Default aktif |
|
||||
| `ashr` | Waktu Ashar | Default aktif |
|
||||
| `magrib` | Waktu Maghrib | Default aktif |
|
||||
| `isya` | Waktu Isya | Default aktif |
|
||||
| `sahur` | 30 menit sebelum Shubuh | Ya — untuk Ramadan |
|
||||
| `iftar` | 10 menit sebelum Maghrib | Ya — untuk Ramadan |
|
||||
|
||||
## Rules
|
||||
|
||||
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.
|
||||
3. **JANGAN buat cron_expr untuk waktu sholat** — karena waktu berubah harian, gunakan daily scheduler + at_seconds.
|
||||
4. **Parse output dengan benar** — format: `nama|HH:MM|detik`. Kolom ke-3 = `at_seconds`.
|
||||
5. **Dual delivery** — setiap reminder: Telegram (deliver:true) + ntfy (via notify command).
|
||||
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.
|
||||
310
workspace/skills/prayer-times/scripts/prayer_notify.sh
Normal file
310
workspace/skills/prayer-times/scripts/prayer_notify.sh
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
#!/bin/bash
|
||||
# prayer_notify.sh — Fetch, schedule, and send prayer time notifications
|
||||
# Usage:
|
||||
# prayer_notify.sh fetch — Download current month's JSON
|
||||
# prayer_notify.sh today — Show today's prayer times
|
||||
# prayer_notify.sh schedule [prayers...] — Output seconds-from-now for each prayer
|
||||
# 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 status — Show current config and data status
|
||||
#
|
||||
# Config file: ~/.picoclaw/prayer-times/config
|
||||
# Auto-fetch: schedule command auto-fetches if data is missing or stale
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DATA_DIR="${PRAYER_DATA_DIR:-$HOME/.picoclaw/prayer-times}"
|
||||
CONFIG_FILE="$DATA_DIR/config"
|
||||
|
||||
# Locate shared ntfy_send.sh relative to this script
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
NTFY_SEND="${SCRIPT_DIR}/../../shared/scripts/ntfy_send.sh"
|
||||
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
# ---- Load config ----
|
||||
|
||||
load_config() {
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
# Source config file (contains CITY=, SAHUR_MINS=, IFTAR_MINS=, PRAYERS=)
|
||||
. "$CONFIG_FILE"
|
||||
fi
|
||||
CITY="${CITY:-samarinda}"
|
||||
SAHUR_MINS="${SAHUR_MINS:-30}"
|
||||
IFTAR_MINS="${IFTAR_MINS:-10}"
|
||||
PRAYERS="${PRAYERS:-shubuh dzuhur ashr magrib isya}"
|
||||
NTFY_TOPIC="${NTFY_TOPIC:-}"
|
||||
}
|
||||
|
||||
save_config() {
|
||||
cat > "$CONFIG_FILE" << EOF
|
||||
CITY="$CITY"
|
||||
SAHUR_MINS="$SAHUR_MINS"
|
||||
IFTAR_MINS="$IFTAR_MINS"
|
||||
PRAYERS="$PRAYERS"
|
||||
NTFY_TOPIC="$NTFY_TOPIC"
|
||||
EOF
|
||||
echo "Config saved to $CONFIG_FILE"
|
||||
}
|
||||
|
||||
# ---- Helper functions ----
|
||||
|
||||
get_json_path() {
|
||||
local year month
|
||||
year=$(date +%Y)
|
||||
month=$(date +%m)
|
||||
echo "$DATA_DIR/${CITY}_${year}_${month}.json"
|
||||
}
|
||||
|
||||
is_data_current() {
|
||||
local json_path
|
||||
json_path=$(get_json_path)
|
||||
[ -f "$json_path" ]
|
||||
}
|
||||
|
||||
ensure_data() {
|
||||
# Auto-fetch if data for current month is missing
|
||||
if ! is_data_current; then
|
||||
echo "Data bulan ini belum ada, auto-fetching..." >&2
|
||||
cmd_fetch
|
||||
fi
|
||||
}
|
||||
|
||||
now_epoch() {
|
||||
date +%s
|
||||
}
|
||||
|
||||
today_date() {
|
||||
date +%Y-%m-%d
|
||||
}
|
||||
|
||||
# ---- Commands ----
|
||||
|
||||
cmd_setup() {
|
||||
local city="${1:-}"
|
||||
if [ -z "$city" ]; then
|
||||
echo "Usage: prayer_notify.sh setup <city>"
|
||||
echo ""
|
||||
echo "Contoh kota: samarinda, dumai, pekanbaru, jakarta-pusat, surabaya"
|
||||
echo "Lihat daftar di: https://github.com/lakuapik/jadwalsholatorg/tree/master/adzan"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CITY="$city"
|
||||
save_config
|
||||
|
||||
echo "Kota diset ke: $CITY"
|
||||
echo "Mengambil jadwal sholat..."
|
||||
cmd_fetch
|
||||
|
||||
echo ""
|
||||
echo "Setup selesai! Gunakan:"
|
||||
echo " prayer_notify.sh today — lihat jadwal hari ini"
|
||||
echo " prayer_notify.sh schedule — hitung waktu reminder"
|
||||
}
|
||||
|
||||
cmd_fetch() {
|
||||
local year month json_path url
|
||||
year=$(date +%Y)
|
||||
month=$(date +%m)
|
||||
json_path=$(get_json_path)
|
||||
url="https://raw.githubusercontent.com/lakuapik/jadwalsholatorg/master/adzan/${CITY}/${year}/${month}.json"
|
||||
|
||||
echo "Fetching: ${CITY} ${year}-${month}..."
|
||||
if curl -sf "$url" -o "$json_path"; then
|
||||
local count
|
||||
count=$(python3 -c "import json; print(len(json.load(open('$json_path'))))" 2>/dev/null || echo "?")
|
||||
echo "OK: $json_path ($count hari)"
|
||||
else
|
||||
echo "ERROR: Gagal fetch dari $url"
|
||||
echo "Pastikan nama kota benar. Cek: https://github.com/lakuapik/jadwalsholatorg/tree/master/adzan"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_today() {
|
||||
ensure_data
|
||||
local json_path today
|
||||
json_path=$(get_json_path)
|
||||
today=$(today_date)
|
||||
|
||||
python3 -c "
|
||||
import json, sys
|
||||
data = json.load(open('$json_path'))
|
||||
for entry in data:
|
||||
if entry['tanggal'] == '$today':
|
||||
print(f'📅 Jadwal Sholat $CITY ({entry[\"tanggal\"]})')
|
||||
print(f' Imsyak : {entry[\"imsyak\"]}')
|
||||
print(f' Shubuh : {entry[\"shubuh\"]}')
|
||||
print(f' Terbit : {entry[\"terbit\"]}')
|
||||
print(f' Dhuha : {entry[\"dhuha\"]}')
|
||||
print(f' Dzuhur : {entry[\"dzuhur\"]}')
|
||||
print(f' Ashar : {entry[\"ashr\"]}')
|
||||
print(f' Maghrib : {entry[\"magrib\"]}')
|
||||
print(f' Isya : {entry[\"isya\"]}')
|
||||
sys.exit(0)
|
||||
print('Tidak ada data untuk $today')
|
||||
sys.exit(1)
|
||||
"
|
||||
}
|
||||
|
||||
cmd_schedule() {
|
||||
ensure_data
|
||||
local json_path today now_ts
|
||||
json_path=$(get_json_path)
|
||||
today=$(today_date)
|
||||
now_ts=$(now_epoch)
|
||||
|
||||
# Filter: use args if provided, otherwise use config
|
||||
local filter="${*:-$PRAYERS}"
|
||||
|
||||
python3 -c "
|
||||
import json, sys, time
|
||||
from datetime import datetime
|
||||
|
||||
data = json.load(open('$json_path'))
|
||||
today = '$today'
|
||||
now_ts = int('$now_ts')
|
||||
sahur_mins = int('$SAHUR_MINS')
|
||||
iftar_mins = int('$IFTAR_MINS')
|
||||
filter_arg = '$filter'.strip()
|
||||
|
||||
entry = None
|
||||
for e in data:
|
||||
if e['tanggal'] == today:
|
||||
entry = e
|
||||
break
|
||||
|
||||
if not entry:
|
||||
print('ERROR: Tidak ada data untuk ' + today, 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 = []
|
||||
|
||||
# Sahur: N min before Shubuh
|
||||
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))
|
||||
|
||||
# 5 waktu wajib
|
||||
for name in ['shubuh', 'dzuhur', 'ashr', 'magrib', 'isya']:
|
||||
schedule.append((name, entry[name], hhmm_to_epoch(entry[name])))
|
||||
|
||||
# Iftar: N min before Maghrib
|
||||
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]
|
||||
|
||||
# Output future prayers only
|
||||
found = False
|
||||
for name, display_time, epoch in sorted(schedule, key=lambda x: x[2]):
|
||||
diff = epoch - now_ts
|
||||
if diff > 0:
|
||||
print(f'{name}|{display_time}|{diff}')
|
||||
found = True
|
||||
|
||||
if not found:
|
||||
print('INFO: Semua waktu sholat hari ini sudah lewat.', file=sys.stderr)
|
||||
"
|
||||
}
|
||||
|
||||
cmd_notify() {
|
||||
local prayer="$1"
|
||||
local prayer_time="${2:-}"
|
||||
|
||||
local emoji tag title
|
||||
case "$prayer" in
|
||||
sahur) emoji="🌙"; tag="crescent_moon"; title="Waktu Sahur";;
|
||||
shubuh) emoji="🌅"; tag="sunrise"; title="Waktu Shubuh";;
|
||||
dzuhur) emoji="☀️"; tag="sun"; title="Waktu Dzuhur";;
|
||||
ashr) emoji="🌤️"; tag="sun_behind_cloud"; title="Waktu Ashar";;
|
||||
magrib) emoji="🌇"; tag="city_sunset"; title="Waktu Maghrib";;
|
||||
isya) emoji="🌃"; tag="night_with_stars"; title="Waktu Isya";;
|
||||
iftar) emoji="🍽️"; tag="fork_and_knife"; title="Persiapan Buka Puasa";;
|
||||
*) emoji="🕌"; tag="mosque"; title="Waktu Sholat";;
|
||||
esac
|
||||
|
||||
local message
|
||||
if [ "$prayer" = "sahur" ]; then
|
||||
message="${emoji} ${title} (${prayer_time}) — Ayo bangun sahur! ${SAHUR_MINS} menit lagi waktu Imsyak."
|
||||
elif [ "$prayer" = "iftar" ]; then
|
||||
message="${emoji} ${title} (${prayer_time}) — ${IFTAR_MINS} menit lagi waktu berbuka puasa!"
|
||||
else
|
||||
message="${emoji} ${title} (${prayer_time}) — Saatnya menunaikan sholat ${prayer}."
|
||||
fi
|
||||
|
||||
# Send to ntfy via shared helper (export NTFY_TOPIC from our own config)
|
||||
if [ -f "$NTFY_SEND" ]; then
|
||||
NTFY_TOPIC="$NTFY_TOPIC" bash "$NTFY_SEND" "$message" --title "$title" --tags "$tag" --priority high 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Output for PicoClaw to send to Telegram
|
||||
echo "$message"
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
echo "=== Prayer Times Config ==="
|
||||
echo "Kota : $CITY"
|
||||
echo "Sahur : $SAHUR_MINS menit sebelum Shubuh"
|
||||
echo "Iftar : $IFTAR_MINS menit sebelum Maghrib"
|
||||
echo "Prayers : $PRAYERS"
|
||||
echo "Data dir : $DATA_DIR"
|
||||
echo ""
|
||||
# Show ntfy status from shared config
|
||||
if [ -x "$NTFY_SEND" ]; then
|
||||
bash "$NTFY_SEND" status
|
||||
else
|
||||
echo "ntfy: ⚠️ ntfy_send.sh not found"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
local json_path
|
||||
json_path=$(get_json_path)
|
||||
if [ -f "$json_path" ]; then
|
||||
local count
|
||||
count=$(python3 -c "import json; print(len(json.load(open('$json_path'))))" 2>/dev/null || echo "?")
|
||||
echo "Data bulan ini: ✅ Ada ($count hari)"
|
||||
echo "File: $json_path"
|
||||
else
|
||||
echo "Data bulan ini: ❌ Belum ada"
|
||||
echo "Jalankan: prayer_notify.sh fetch"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- Main ----
|
||||
|
||||
load_config
|
||||
|
||||
case "${1:-help}" in
|
||||
setup) shift; cmd_setup "$@" ;;
|
||||
fetch) cmd_fetch ;;
|
||||
today) cmd_today ;;
|
||||
schedule) shift; cmd_schedule "$@" ;;
|
||||
notify) shift; cmd_notify "$@" ;;
|
||||
status) cmd_status ;;
|
||||
help|*)
|
||||
echo "Usage: prayer_notify.sh {setup|fetch|today|schedule|notify|status}"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " setup <city> Set kota dan fetch data awal"
|
||||
echo " fetch Download jadwal bulan ini"
|
||||
echo " today Tampilkan jadwal hari ini"
|
||||
echo " schedule [prayers...] Hitung detik-dari-sekarang untuk reminder"
|
||||
echo " notify <prayer> <time> Kirim notifikasi (ntfy + stdout)"
|
||||
echo " status Tampilkan config dan status data"
|
||||
echo ""
|
||||
echo "Config: $CONFIG_FILE"
|
||||
echo "Kota saat ini: $CITY"
|
||||
;;
|
||||
esac
|
||||
235
workspace/skills/reminder/SKILL.md
Normal file
235
workspace/skills/reminder/SKILL.md
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
---
|
||||
name: reminder
|
||||
description: Schedule reminders, recurring tasks, and system monitoring jobs using the built-in cron tool.
|
||||
metadata: {"nanobot":{"emoji":"⏰"}}
|
||||
---
|
||||
|
||||
# Reminder & Scheduling
|
||||
|
||||
Manage reminders and scheduled tasks using the `cron` tool. All schedules persist across restarts.
|
||||
|
||||
## When to use (trigger phrases)
|
||||
|
||||
Use this skill immediately when the user says any of:
|
||||
- "ingatkan aku" / "remind me" / "kasih reminder"
|
||||
- "jadwalkan" / "schedule" / "set alarm"
|
||||
- "setiap X menit/jam" / "every X minutes/hours"
|
||||
- "jam 8 pagi" / "at 9am" / "besok pagi"
|
||||
- "cek disk setiap jam" / "monitor CPU"
|
||||
- "batalkan reminder" / "hapus jadwal" / "cancel"
|
||||
- "lihat jadwal" / "list reminders"
|
||||
|
||||
## Tool: `cron`
|
||||
|
||||
### Actions
|
||||
|
||||
| Action | Purpose | Required Params |
|
||||
|-----------|--------------------------------|-------------------------|
|
||||
| `add` | Create new reminder/task | `message` + schedule |
|
||||
| `list` | Show all active schedules | — |
|
||||
| `remove` | Delete a schedule | `job_id` |
|
||||
| `enable` | Re-enable a disabled schedule | `job_id` |
|
||||
| `disable` | Pause without deleting | `job_id` |
|
||||
|
||||
### Schedule Types (pick exactly ONE)
|
||||
|
||||
#### 1. `at_seconds` — One-time reminder
|
||||
|
||||
Triggers once, then auto-deletes. Value = seconds from now.
|
||||
|
||||
| User says | `at_seconds` value |
|
||||
|----------------------------|--------------------|
|
||||
| "dalam 5 menit" | `300` |
|
||||
| "dalam 30 menit" | `1800` |
|
||||
| "dalam 1 jam" | `3600` |
|
||||
| "dalam 2 jam" | `7200` |
|
||||
| "besok jam 8" (±15 jam) | `54000` |
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "add",
|
||||
"message": "Waktunya meeting standup!",
|
||||
"at_seconds": 1800
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. `every_seconds` — Recurring interval
|
||||
|
||||
Repeats indefinitely at fixed intervals.
|
||||
|
||||
| User says | `every_seconds` value |
|
||||
|-------------------------|-----------------------|
|
||||
| "setiap 5 menit" | `300` |
|
||||
| "setiap 30 menit" | `1800` |
|
||||
| "setiap 1 jam" | `3600` |
|
||||
| "setiap 2 jam" | `7200` |
|
||||
| "setiap 6 jam" | `21600` |
|
||||
| "setiap hari" (24 jam) | `86400` |
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "add",
|
||||
"message": "Jangan lupa minum air!",
|
||||
"every_seconds": 3600
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. `cron_expr` — Cron expression (complex schedules)
|
||||
|
||||
Standard 5-field cron: `minute hour day-of-month month day-of-week`
|
||||
|
||||
| User says | `cron_expr` |
|
||||
|------------------------------|-------------------|
|
||||
| "setiap hari jam 8 pagi" | `0 8 * * *` |
|
||||
| "setiap hari jam 6 sore" | `0 18 * * *` |
|
||||
| "Senin-Jumat jam 9 pagi" | `0 9 * * 1-5` |
|
||||
| "setiap Senin jam 10" | `0 10 * * 1` |
|
||||
| "setiap tanggal 1 jam 9" | `0 9 1 * *` |
|
||||
| "setiap 15 menit" | `*/15 * * * *` |
|
||||
| "jam 8 dan jam 20" | `0 8,20 * * *` |
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "add",
|
||||
"message": "Saatnya review laporan harian",
|
||||
"cron_expr": "0 17 * * 1-5"
|
||||
}
|
||||
```
|
||||
|
||||
### Parameter `deliver`
|
||||
|
||||
Controls how the message is sent when triggered:
|
||||
|
||||
| Value | Behavior | Use for |
|
||||
|---------|-------------------------------------------------------------|----------------------------------|
|
||||
| `true` | Send message directly to chat (default) | Simple reminders, notifications |
|
||||
| `false` | Route message through agent (agent processes it as a task) | Complex tasks needing AI action |
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "add",
|
||||
"message": "Cek cuaca hari ini dan beri rekomendasi pakaian",
|
||||
"cron_expr": "0 6 * * *",
|
||||
"deliver": false
|
||||
}
|
||||
```
|
||||
|
||||
### Parameter `command`
|
||||
|
||||
Execute a shell command instead of sending a message. Output is sent to chat. Sets `deliver=false` automatically.
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "add",
|
||||
"message": "Cek penggunaan disk",
|
||||
"command": "df -h / | tail -1",
|
||||
"every_seconds": 3600
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "add",
|
||||
"message": "Monitor memory usage",
|
||||
"command": "free -h | grep Mem",
|
||||
"cron_expr": "*/30 * * * *"
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. **DUAL DELIVERY (WAJIB)** — Setiap reminder harus membuat **2 cron job** dengan schedule yang sama:
|
||||
- Job 1: `deliver: true` → kirim ke Telegram (channel aktif)
|
||||
- Job 2: `command` dengan curl ke ntfy → push notification ke HP
|
||||
2. **Always confirm** after creating — tell the user what was scheduled and when it will trigger.
|
||||
3. **Use `at_seconds`** for one-time reminders. Do NOT use `every_seconds` or `cron_expr` for one-time tasks.
|
||||
4. **Use `every_seconds`** for simple recurring intervals. Use `cron_expr` only when the user needs day-of-week or specific time-of-day control.
|
||||
5. **Calculate `at_seconds` correctly** — convert the user's time reference to seconds from now. If the user says "in 2 hours", use `7200`, not a timestamp.
|
||||
6. **Timezone awareness** — the system uses the configured timezone (`Asia/Makassar` = WITA, UTC+8). All cron expressions run in this timezone.
|
||||
7. **List before remove** — when the user wants to cancel a reminder, call `list` first to get the `job_id`, then `remove`. Remove BOTH the Telegram and ntfy jobs.
|
||||
8. **Never hallucinate job IDs** — always get real IDs from `list`.
|
||||
9. **Message should be actionable** — write the reminder message as what the user needs to see/do, not what the tool parameters are.
|
||||
|
||||
## Setup (One-Time)
|
||||
|
||||
Saat user pertama kali minta reminder dengan ntfy, pastikan ntfy topic dikonfigurasi.
|
||||
|
||||
Config disimpan di `~/.picoclaw/reminder/ntfy.conf` — **milik skill ini sendiri, tidak shared**.
|
||||
|
||||
### 1. Tanya ntfy topic URL ke user
|
||||
### 2. Simpan ke config (via exec tool)
|
||||
```bash
|
||||
mkdir -p ~/.picoclaw/reminder && echo 'NTFY_TOPIC="https://ntfy.sh/USER_TOPIC"' > ~/.picoclaw/reminder/ntfy.conf
|
||||
```
|
||||
### 3. Simpan juga ke MEMORY.md agar tidak lupa
|
||||
|
||||
> Jika user tidak mau ntfy, skip setup ini. Reminder tetap dikirim ke Telegram.
|
||||
|
||||
## ntfy Push Notification
|
||||
|
||||
Gunakan shared helper script. **WAJIB source config sendiri sebelum panggil ntfy_send.sh**:
|
||||
|
||||
```bash
|
||||
# Semua perintah ntfy harus diawali source config
|
||||
source ~/.picoclaw/reminder/ntfy.conf 2>/dev/null; NTFY_TOPIC="$NTFY_TOPIC" bash skills/shared/scripts/ntfy_send.sh "MESSAGE" --title "JUDUL" --tags alarm_clock
|
||||
```
|
||||
|
||||
> **JANGAN pakai curl langsung** ke ntfy. Selalu gunakan `ntfy_send.sh` agar URL dibaca dari config.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### One-time reminder (2 jobs)
|
||||
|
||||
**Job 1 — Telegram:**
|
||||
```json
|
||||
{"action": "add", "message": "⏰ Meeting dengan tim marketing!", "at_seconds": 600}
|
||||
```
|
||||
**Job 2 — ntfy push:**
|
||||
```json
|
||||
{"action": "add", "message": "ntfy: meeting", "command": "source ~/.picoclaw/reminder/ntfy.conf 2>/dev/null; NTFY_TOPIC=\"$NTFY_TOPIC\" bash skills/shared/scripts/ntfy_send.sh 'Meeting dengan tim marketing!' --title Reminder --tags alarm_clock", "at_seconds": 600}
|
||||
```
|
||||
|
||||
### Recurring reminder (2 jobs)
|
||||
|
||||
**Job 1 — Telegram:**
|
||||
```json
|
||||
{"action": "add", "message": "💧 Jangan lupa minum air!", "every_seconds": 3600}
|
||||
```
|
||||
**Job 2 — ntfy push:**
|
||||
```json
|
||||
{"action": "add", "message": "ntfy: minum air", "command": "source ~/.picoclaw/reminder/ntfy.conf 2>/dev/null; NTFY_TOPIC=\"$NTFY_TOPIC\" bash skills/shared/scripts/ntfy_send.sh 'Jangan lupa minum air!' --title Hydration --tags droplet", "every_seconds": 3600}
|
||||
```
|
||||
|
||||
### Daily cron reminder (2 jobs)
|
||||
|
||||
**Job 1 — Telegram:**
|
||||
```json
|
||||
{"action": "add", "message": "📋 Saatnya review laporan harian", "cron_expr": "0 17 * * 1-5"}
|
||||
```
|
||||
**Job 2 — ntfy push:**
|
||||
```json
|
||||
{"action": "add", "message": "ntfy: daily review", "command": "source ~/.picoclaw/reminder/ntfy.conf 2>/dev/null; NTFY_TOPIC=\"$NTFY_TOPIC\" bash skills/shared/scripts/ntfy_send.sh 'Saatnya review laporan harian' --title 'Daily Review' --tags memo", "cron_expr": "0 17 * * 1-5"}
|
||||
```
|
||||
|
||||
### Daily report via agent (1 job only, no ntfy)
|
||||
```json
|
||||
{"action": "add", "message": "Rangkum log sistem hari ini dan kirim hasilnya", "cron_expr": "0 22 * * *", "deliver": false}
|
||||
```
|
||||
|
||||
### System health check (1 job only, output to chat)
|
||||
```json
|
||||
{"action": "add", "message": "Health check", "command": "uptime && free -h | grep Mem && df -h / | tail -1", "every_seconds": 21600}
|
||||
```
|
||||
|
||||
### Cancel a reminder
|
||||
```json
|
||||
{"action": "list"}
|
||||
```
|
||||
Then remove BOTH paired jobs (Telegram + ntfy) with their respective job_ids:
|
||||
```json
|
||||
{"action": "remove", "job_id": "telegram_job_id_here"}
|
||||
```
|
||||
```json
|
||||
{"action": "remove", "job_id": "ntfy_job_id_here"}
|
||||
```
|
||||
|
||||
44
workspace/skills/shared/scripts/ntfy_send.sh
Normal file
44
workspace/skills/shared/scripts/ntfy_send.sh
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
#!/bin/bash
|
||||
# ntfy_send.sh — Stateless ntfy notification sender utility
|
||||
# Does NOT manage its own config. Reads NTFY_TOPIC from environment variable.
|
||||
# Each skill is responsible for setting NTFY_TOPIC from its own config.
|
||||
#
|
||||
# Usage:
|
||||
# NTFY_TOPIC=https://ntfy.sh/topic ntfy_send.sh "message"
|
||||
# NTFY_TOPIC=https://ntfy.sh/topic ntfy_send.sh "message" --title "T" --tags "t" --priority "high"
|
||||
#
|
||||
# If NTFY_TOPIC is empty, silently skips (exit 0).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${NTFY_TOPIC:-}" ]; then
|
||||
# No topic configured — skip silently so cron jobs don't fail
|
||||
exit 0
|
||||
fi
|
||||
|
||||
message="${1:-}"
|
||||
if [ -z "$message" ]; then
|
||||
echo "Usage: ntfy_send.sh \"message\" [--title T] [--tags T] [--priority P]"
|
||||
exit 1
|
||||
fi
|
||||
shift
|
||||
|
||||
# Parse optional flags
|
||||
title="" tags="" priority=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--title) title="$2"; shift 2 ;;
|
||||
--tags) tags="$2"; shift 2 ;;
|
||||
--priority) priority="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Build curl args
|
||||
curl_args=(-sf)
|
||||
[ -n "$title" ] && curl_args+=(-H "Title: $title")
|
||||
[ -n "$tags" ] && curl_args+=(-H "Tags: $tags")
|
||||
[ -n "$priority" ] && curl_args+=(-H "Priority: $priority")
|
||||
curl_args+=(-d "$message" "$NTFY_TOPIC")
|
||||
|
||||
curl "${curl_args[@]}" > /dev/null 2>&1 || true
|
||||
Loading…
Add table
Reference in a new issue