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:
muava12 2026-02-24 08:54:14 +08:00
parent 29b6e486ff
commit 5742374ce7
8 changed files with 1429 additions and 4 deletions

35
PICOCLAW_MANAGER_API.md Normal file
View 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
View 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()

View file

@ -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
View 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

View 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.

View 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

View 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"}
```

View 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