Initial commit: agent_logwatch v1.0
- Réception logs MQTT depuis machines distantes (agents/logwatch/+/logs) - Pré-filtrage sans LLM (14 patterns: ERROR, FATAL, OOM, segfault, auth fail...) - Analyse LLM par créneau horaire configurable (APScheduler) - Gestion round-robin avec reprise sur interruption - Extension de créneau (+30 min) avec confirmation admin - Skills: machine (gestion machines) + logwatch (contrôle) - Script send_logs.sh pour machines distantes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Skill AGENTS_STATUS — afficher le statut en temps réel de tous les agents.
|
||||
|
||||
Usage LLM : SKILL:agents_status ARGS:
|
||||
"""
|
||||
DESCRIPTION = "Afficher le statut en temps réel de tous les agents (online/offline)"
|
||||
USAGE = "SKILL:agents_status ARGS:(aucun argument)"
|
||||
|
||||
|
||||
def run(args: str, context) -> str:
|
||||
with context.agent._online_lock:
|
||||
online = set(context.agent._online_agents)
|
||||
|
||||
all_caps = context.registry.all_agents()
|
||||
|
||||
if not all_caps:
|
||||
return "Aucun agent connu dans le registre."
|
||||
|
||||
lines = ["── Statut des agents ──────────────────"]
|
||||
for caps in sorted(all_caps, key=lambda c: c.agent_id):
|
||||
if caps.agent_id == context.agent_id:
|
||||
continue # Ne pas s'afficher soi-même
|
||||
icon = "🟢" if caps.agent_id in online else "🔴"
|
||||
label = "en ligne" if caps.agent_id in online else "hors ligne"
|
||||
lines.append(f" {icon} {caps.agent_id} [{caps.agent_type}] — {label}")
|
||||
lines.append(f" {caps.description}")
|
||||
|
||||
return "\n".join(lines) if len(lines) > 1 else "Aucun autre agent connu."
|
||||
@@ -0,0 +1,268 @@
|
||||
"""
|
||||
Skill LOGWATCH — contrôle de l'agent : schedule, analyse à la demande, statut.
|
||||
|
||||
Usage LLM :
|
||||
SKILL:logwatch ARGS:status
|
||||
SKILL:logwatch ARGS:schedule show
|
||||
SKILL:logwatch ARGS:schedule set <HH:MM-HH:MM>
|
||||
SKILL:logwatch ARGS:schedule enable
|
||||
SKILL:logwatch ARGS:schedule disable
|
||||
SKILL:logwatch ARGS:overage <minutes>
|
||||
SKILL:logwatch ARGS:analyze <hostname>
|
||||
SKILL:logwatch ARGS:analyze_all
|
||||
SKILL:logwatch ARGS:retention <jours>
|
||||
SKILL:logwatch ARGS:logs <hostname> [N]
|
||||
SKILL:logwatch ARGS:reset <hostname>
|
||||
"""
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
DESCRIPTION = "Contrôle LogWatch : schedule, analyse à la demande, statut, logs en attente"
|
||||
USAGE = (
|
||||
"SKILL:logwatch ARGS:status\n"
|
||||
"SKILL:logwatch ARGS:schedule show\n"
|
||||
"SKILL:logwatch ARGS:schedule set <HH:MM-HH:MM>\n"
|
||||
"SKILL:logwatch ARGS:schedule enable|disable\n"
|
||||
"SKILL:logwatch ARGS:overage <minutes>\n"
|
||||
"SKILL:logwatch ARGS:analyze <hostname>\n"
|
||||
"SKILL:logwatch ARGS:analyze_all\n"
|
||||
"SKILL:logwatch ARGS:retention <jours>\n"
|
||||
"SKILL:logwatch ARGS:logs <hostname> [N]\n"
|
||||
"SKILL:logwatch ARGS:reset <hostname>"
|
||||
)
|
||||
|
||||
|
||||
def _db(context):
|
||||
return context.agent._get_db()
|
||||
|
||||
|
||||
def _cfg(context, key, default=''):
|
||||
return context.agent._cfg(key, default)
|
||||
|
||||
|
||||
def _set_cfg(context, key, value):
|
||||
context.agent._set_cfg(key, value)
|
||||
|
||||
|
||||
def run(args: str, context) -> str:
|
||||
parts = args.strip().split(None, 1)
|
||||
action = parts[0].lower() if parts else 'status'
|
||||
rest = parts[1].strip() if len(parts) > 1 else ''
|
||||
|
||||
# ── status ────────────────────────────────────────────────────────────────
|
||||
if action == 'status':
|
||||
agent = context.agent
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
enabled = _cfg(context, 'enabled', '1') == '1'
|
||||
start = _cfg(context, 'analysis_start', '02:00')
|
||||
end = _cfg(context, 'analysis_end', '04:00')
|
||||
max_ov = _cfg(context, 'max_overage_minutes', '30')
|
||||
retention = _cfg(context, 'log_retention_days', '7')
|
||||
|
||||
is_running = (
|
||||
agent._analysis_thread is not None and
|
||||
agent._analysis_thread.is_alive()
|
||||
)
|
||||
|
||||
with _db(context) as conn:
|
||||
nb_machines = conn.execute(
|
||||
"SELECT COUNT(*) FROM machines WHERE active=1"
|
||||
).fetchone()[0]
|
||||
nb_pending = conn.execute(
|
||||
"SELECT COUNT(*) FROM filtered_logs WHERE analyzed=0"
|
||||
).fetchone()[0]
|
||||
today_sessions = conn.execute(
|
||||
"SELECT COUNT(*) as cnt, status FROM analysis_sessions "
|
||||
"WHERE slot_date=? GROUP BY status",
|
||||
(today,)
|
||||
).fetchall()
|
||||
|
||||
schedule_status = f"{'✅ activé' if enabled else '❌ désactivé'} ({start} → {end})"
|
||||
analysis_status = "🔄 en cours" if is_running else "⏸️ idle"
|
||||
|
||||
lines = [
|
||||
"── Statut LogWatch ────────────────────────────",
|
||||
f" Analyse auto : {schedule_status}",
|
||||
f" Analyse actuel: {analysis_status}",
|
||||
f" Dépassement : max {max_ov} min",
|
||||
f" Rétention logs: {retention} jours",
|
||||
f" Machines activ: {nb_machines}",
|
||||
f" Logs en attent: {nb_pending} erreurs filtrées",
|
||||
f" Auj. ({today}):",
|
||||
]
|
||||
for s in today_sessions:
|
||||
lines.append(f" {s['status']}: {s['cnt']} machine(s)")
|
||||
|
||||
if agent._pending_extension:
|
||||
host = agent._pending_extension.get('hostname', '?')
|
||||
lines.append(f" ⏰ Extension en attente pour: {host}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── schedule ──────────────────────────────────────────────────────────────
|
||||
if action == 'schedule':
|
||||
sub_parts = rest.split(None, 1)
|
||||
sub = sub_parts[0].lower() if sub_parts else 'show'
|
||||
sub_rest = sub_parts[1].strip() if len(sub_parts) > 1 else ''
|
||||
|
||||
if sub == 'show':
|
||||
start = _cfg(context, 'analysis_start', '02:00')
|
||||
end = _cfg(context, 'analysis_end', '04:00')
|
||||
enabled = _cfg(context, 'enabled', '1') == '1'
|
||||
return (
|
||||
f"Créneau d'analyse : {start} → {end}\n"
|
||||
f"État : {'activé ✅' if enabled else 'désactivé ❌'}"
|
||||
)
|
||||
|
||||
if sub == 'set':
|
||||
# Format : HH:MM-HH:MM
|
||||
if '-' not in sub_rest:
|
||||
return "Format: schedule set HH:MM-HH:MM (ex: 02:00-04:00)"
|
||||
try:
|
||||
start_s, end_s = sub_rest.split('-', 1)
|
||||
# Validation
|
||||
sh, sm = map(int, start_s.strip().split(':'))
|
||||
eh, em = map(int, end_s.strip().split(':'))
|
||||
if not (0 <= sh < 24 and 0 <= sm < 60 and 0 <= eh < 24 and 0 <= em < 60):
|
||||
return "Heures invalides."
|
||||
except ValueError:
|
||||
return "Format: HH:MM-HH:MM"
|
||||
_set_cfg(context, 'analysis_start', start_s.strip())
|
||||
_set_cfg(context, 'analysis_end', end_s.strip())
|
||||
context.agent._reload_schedule()
|
||||
return f"✅ Créneau mis à jour : {start_s.strip()} → {end_s.strip()}"
|
||||
|
||||
if sub in ('enable', 'disable'):
|
||||
val = '1' if sub == 'enable' else '0'
|
||||
_set_cfg(context, 'enabled', val)
|
||||
context.agent._reload_schedule()
|
||||
return f"✅ Analyse automatique {'activée' if val=='1' else 'désactivée'}."
|
||||
|
||||
return "Sub-commande inconnue. Utilise : show, set <HH:MM-HH:MM>, enable, disable"
|
||||
|
||||
# ── overage ───────────────────────────────────────────────────────────────
|
||||
if action == 'overage':
|
||||
try:
|
||||
minutes = int(rest)
|
||||
if minutes < 0:
|
||||
return "La valeur doit être >= 0."
|
||||
except ValueError:
|
||||
return "Format: overage <minutes>"
|
||||
_set_cfg(context, 'max_overage_minutes', str(minutes))
|
||||
return f"✅ Dépassement max : {minutes} min."
|
||||
|
||||
# ── retention ─────────────────────────────────────────────────────────────
|
||||
if action == 'retention':
|
||||
try:
|
||||
days = int(rest)
|
||||
if days < 1:
|
||||
return "Minimum 1 jour."
|
||||
except ValueError:
|
||||
return "Format: retention <jours>"
|
||||
_set_cfg(context, 'log_retention_days', str(days))
|
||||
return f"✅ Rétention logs : {days} jours."
|
||||
|
||||
# ── analyze <hostname> ────────────────────────────────────────────────────
|
||||
if action == 'analyze':
|
||||
hostname = rest.strip()
|
||||
if not hostname:
|
||||
return "Format: analyze <hostname>"
|
||||
|
||||
with _db(context) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM machines WHERE hostname=? AND active=1", (hostname,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return f"Machine '{hostname}' introuvable ou inactive."
|
||||
|
||||
machine_id = row['id']
|
||||
|
||||
def _run_now():
|
||||
agent = context.agent
|
||||
# Créneau fictif généreux pour l'analyse à la demande
|
||||
agent._slot_end_time = datetime.now() + timedelta(hours=4)
|
||||
agent._analysis_stop.clear()
|
||||
agent._analyze_machine(machine_id, hostname)
|
||||
|
||||
t = threading.Thread(target=_run_now, daemon=True, name=f"logwatch-demand-{hostname}")
|
||||
t.start()
|
||||
return f"🚀 Analyse de **{hostname}** lancée (arrière-plan)."
|
||||
|
||||
# ── analyze_all ───────────────────────────────────────────────────────────
|
||||
if action == 'analyze_all':
|
||||
agent = context.agent
|
||||
if agent._analysis_thread and agent._analysis_thread.is_alive():
|
||||
return "⚠️ Une analyse est déjà en cours."
|
||||
|
||||
def _run_all():
|
||||
agent._slot_end_time = datetime.now() + timedelta(hours=8)
|
||||
agent._analysis_stop.clear()
|
||||
agent._analysis_loop()
|
||||
|
||||
t = threading.Thread(target=_run_all, daemon=True, name="logwatch-demand-all")
|
||||
t.start()
|
||||
return "🚀 Analyse complète de toutes les machines lancée (arrière-plan)."
|
||||
|
||||
# ── logs <hostname> [N] ───────────────────────────────────────────────────
|
||||
if action == 'logs':
|
||||
p = rest.split(None, 1)
|
||||
hostname = p[0].strip() if p else ''
|
||||
try:
|
||||
limit = int(p[1]) if len(p) > 1 else 20
|
||||
except ValueError:
|
||||
limit = 20
|
||||
|
||||
if not hostname:
|
||||
return "Format: logs <hostname> [N]"
|
||||
|
||||
with _db(context) as conn:
|
||||
m = conn.execute(
|
||||
"SELECT id FROM machines WHERE hostname=?", (hostname,)
|
||||
).fetchone()
|
||||
if not m:
|
||||
return f"Machine '{hostname}' introuvable."
|
||||
rows = conn.execute(
|
||||
"SELECT log_line, severity, received_at, analyzed "
|
||||
"FROM filtered_logs WHERE machine_id=? ORDER BY id DESC LIMIT ?",
|
||||
(m['id'], limit)
|
||||
).fetchall()
|
||||
|
||||
if not rows:
|
||||
return f"Aucun log filtré pour {hostname}."
|
||||
|
||||
lines = [f"── {limit} derniers logs filtrés de {hostname} ──"]
|
||||
for r in rows:
|
||||
ana = "✓" if r['analyzed'] else "○"
|
||||
lines.append(
|
||||
f" {ana} [{r['received_at'][:16]}][{r['severity']:8s}] {r['log_line'][:120]}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── reset <hostname> ──────────────────────────────────────────────────────
|
||||
if action == 'reset':
|
||||
hostname = rest.strip()
|
||||
if not hostname:
|
||||
return "Format: reset <hostname>"
|
||||
with _db(context) as conn:
|
||||
m = conn.execute(
|
||||
"SELECT id FROM machines WHERE hostname=?", (hostname,)
|
||||
).fetchone()
|
||||
if not m:
|
||||
return f"Machine '{hostname}' introuvable."
|
||||
# Réinitialise les sessions et marque les logs comme non-analysés
|
||||
conn.execute(
|
||||
"DELETE FROM analysis_sessions WHERE machine_id=?", (m['id'],)
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE filtered_logs SET analyzed=0 WHERE machine_id=?", (m['id'],)
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE machines SET last_analyzed_at=NULL WHERE id=?", (m['id'],)
|
||||
)
|
||||
return f"✅ {hostname} réinitialisée — tous les logs seront ré-analysés."
|
||||
|
||||
return (
|
||||
"Action inconnue. Disponible : status, schedule, overage, retention, "
|
||||
"analyze, analyze_all, logs, reset"
|
||||
)
|
||||
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
Skill MACHINE — gestion des machines qui envoient leurs logs.
|
||||
|
||||
Usage LLM :
|
||||
SKILL:machine ARGS:list
|
||||
SKILL:machine ARGS:queue
|
||||
SKILL:machine ARGS:add <hostname>
|
||||
SKILL:machine ARGS:remove <hostname>
|
||||
SKILL:machine ARGS:status <hostname>
|
||||
SKILL:machine ARGS:reorder <hostname> <position>
|
||||
SKILL:machine ARGS:activate <hostname>
|
||||
SKILL:machine ARGS:deactivate <hostname>
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
DESCRIPTION = "Gestion des machines enregistrées : liste, file d'attente, ajout, suppression, statut"
|
||||
USAGE = (
|
||||
"SKILL:machine ARGS:list\n"
|
||||
"SKILL:machine ARGS:queue\n"
|
||||
"SKILL:machine ARGS:add <hostname>\n"
|
||||
"SKILL:machine ARGS:remove <hostname>\n"
|
||||
"SKILL:machine ARGS:status <hostname>\n"
|
||||
"SKILL:machine ARGS:reorder <hostname> <position>\n"
|
||||
"SKILL:machine ARGS:activate <hostname>\n"
|
||||
"SKILL:machine ARGS:deactivate <hostname>"
|
||||
)
|
||||
|
||||
|
||||
def _db(context):
|
||||
return context.agent._get_db()
|
||||
|
||||
|
||||
def run(args: str, context) -> str:
|
||||
parts = args.strip().split(None, 1)
|
||||
action = parts[0].lower() if parts else 'list'
|
||||
rest = parts[1].strip() if len(parts) > 1 else ''
|
||||
|
||||
# ── list ──────────────────────────────────────────────────────────────────
|
||||
if action == 'list':
|
||||
with _db(context) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT hostname, active, last_log_at, last_analyzed_at, queue_position "
|
||||
"FROM machines ORDER BY queue_position ASC"
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return "Aucune machine enregistrée."
|
||||
lines = ["── Machines enregistrées ─────────────────────"]
|
||||
for r in rows:
|
||||
status = "🟢 actif" if r['active'] else "🔴 inactif"
|
||||
last_log = r['last_log_at'][:16] if r['last_log_at'] else "jamais"
|
||||
last_ana = r['last_analyzed_at'][:16] if r['last_analyzed_at'] else "jamais"
|
||||
lines.append(
|
||||
f" [{r['queue_position']:2d}] {r['hostname']:<30s} {status}\n"
|
||||
f" Dernier log: {last_log} | Dernière analyse: {last_ana}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── queue ─────────────────────────────────────────────────────────────────
|
||||
if action == 'queue':
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
with _db(context) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT m.hostname, m.queue_position, m.active, "
|
||||
" COALESCE(s.status, 'pending') as session_status "
|
||||
"FROM machines m "
|
||||
"LEFT JOIN analysis_sessions s "
|
||||
" ON s.machine_id=m.id AND s.slot_date=? "
|
||||
"ORDER BY m.queue_position ASC",
|
||||
(today,)
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return "Aucune machine dans la file."
|
||||
icons = {'done': '✅', 'in_progress': '🔄', 'paused': '⏸️', 'pending': '⏳'}
|
||||
lines = [f"── File d'analyse — {today} ─────────────────"]
|
||||
for r in rows:
|
||||
active = "" if r['active'] else " [inactif]"
|
||||
icon = icons.get(r['session_status'], '⏳')
|
||||
lines.append(
|
||||
f" {r['queue_position']:2d}. {icon} {r['hostname']}{active} "
|
||||
f"({r['session_status']})"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── add ───────────────────────────────────────────────────────────────────
|
||||
if action == 'add':
|
||||
hostname = rest.strip()
|
||||
if not hostname:
|
||||
return "Format: machine add <hostname>"
|
||||
with _db(context) as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM machines WHERE hostname=?", (hostname,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
return f"Machine '{hostname}' déjà enregistrée."
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(queue_position), 0) FROM machines"
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT INTO machines (hostname, registered_at, queue_position) VALUES (?,?,?)",
|
||||
(hostname, datetime.now().isoformat(), max_pos + 1)
|
||||
)
|
||||
return f"✅ Machine '{hostname}' enregistrée (position {max_pos + 1})."
|
||||
|
||||
# ── remove ────────────────────────────────────────────────────────────────
|
||||
if action == 'remove':
|
||||
hostname = rest.strip()
|
||||
if not hostname:
|
||||
return "Format: machine remove <hostname>"
|
||||
with _db(context) as conn:
|
||||
cur = conn.execute("DELETE FROM machines WHERE hostname=?", (hostname,))
|
||||
if cur.rowcount == 0:
|
||||
return f"Machine '{hostname}' introuvable."
|
||||
return f"🗑️ Machine '{hostname}' supprimée."
|
||||
|
||||
# ── status ────────────────────────────────────────────────────────────────
|
||||
if action == 'status':
|
||||
hostname = rest.strip()
|
||||
if not hostname:
|
||||
return "Format: machine status <hostname>"
|
||||
with _db(context) as conn:
|
||||
m = conn.execute(
|
||||
"SELECT * FROM machines WHERE hostname=?", (hostname,)
|
||||
).fetchone()
|
||||
if not m:
|
||||
return f"Machine '{hostname}' introuvable."
|
||||
# Logs filtrés en attente
|
||||
pending_logs = conn.execute(
|
||||
"SELECT COUNT(*) as cnt FROM filtered_logs WHERE machine_id=? AND analyzed=0",
|
||||
(m['id'],)
|
||||
).fetchone()['cnt']
|
||||
# Sessions récentes
|
||||
sessions = conn.execute(
|
||||
"SELECT slot_date, status, started_at, completed_at, last_log_id "
|
||||
"FROM analysis_sessions WHERE machine_id=? ORDER BY slot_date DESC LIMIT 5",
|
||||
(m['id'],)
|
||||
).fetchall()
|
||||
|
||||
active = "actif" if m['active'] else "inactif"
|
||||
lines = [
|
||||
f"── Statut de {hostname} ──────────────────────",
|
||||
f" Statut : {active}",
|
||||
f" Position : {m['queue_position']}",
|
||||
f" Enregistrée : {m['registered_at'][:16]}",
|
||||
f" Dernier log : {m['last_log_at'][:16] if m['last_log_at'] else 'jamais'}",
|
||||
f" Dernière ana: {m['last_analyzed_at'][:16] if m['last_analyzed_at'] else 'jamais'}",
|
||||
f" Logs en att.: {pending_logs}",
|
||||
]
|
||||
if sessions:
|
||||
lines.append(" Sessions récentes:")
|
||||
for s in sessions:
|
||||
lines.append(
|
||||
f" {s['slot_date']} : {s['status']} "
|
||||
f"(offset log #{s['last_log_id']})"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── reorder ───────────────────────────────────────────────────────────────
|
||||
if action == 'reorder':
|
||||
p = rest.split(None, 1)
|
||||
if len(p) < 2:
|
||||
return "Format: machine reorder <hostname> <nouvelle_position>"
|
||||
hostname = p[0].strip()
|
||||
try:
|
||||
new_pos = int(p[1].strip())
|
||||
except ValueError:
|
||||
return "La position doit être un entier."
|
||||
with _db(context) as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE machines SET queue_position=? WHERE hostname=?",
|
||||
(new_pos, hostname)
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
return f"Machine '{hostname}' introuvable."
|
||||
return f"✅ {hostname} déplacée en position {new_pos}."
|
||||
|
||||
# ── activate / deactivate ─────────────────────────────────────────────────
|
||||
if action in ('activate', 'deactivate'):
|
||||
hostname = rest.strip()
|
||||
if not hostname:
|
||||
return f"Format: machine {action} <hostname>"
|
||||
val = 1 if action == 'activate' else 0
|
||||
with _db(context) as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE machines SET active=? WHERE hostname=?", (val, hostname)
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
return f"Machine '{hostname}' introuvable."
|
||||
verb = "activée" if val else "désactivée"
|
||||
return f"✅ Machine '{hostname}' {verb}."
|
||||
|
||||
return (
|
||||
"Action inconnue. Disponible : list, queue, add, remove, status, "
|
||||
"reorder, activate, deactivate"
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Skill MQTT_SEND — publier un message sur n'importe quel topic MQTT.
|
||||
Permet à l'agent de communiquer proactivement avec d'autres agents.
|
||||
|
||||
Usage LLM : SKILL:mqtt_send ARGS:<topic> | <message>
|
||||
"""
|
||||
DESCRIPTION = "Publier un message sur un topic MQTT (communication inter-agents)"
|
||||
USAGE = "SKILL:mqtt_send ARGS:<topic> | <message>"
|
||||
|
||||
|
||||
def run(args: str, context) -> str:
|
||||
if "|" not in args:
|
||||
return "Format : SKILL:mqtt_send ARGS:<topic> | <message>"
|
||||
|
||||
topic, message = args.split("|", 1)
|
||||
topic = topic.strip()
|
||||
message = message.strip()
|
||||
|
||||
if not topic:
|
||||
return "Topic vide."
|
||||
|
||||
context.mqtt.publish_raw(topic, message)
|
||||
return f"Message publié sur '{topic}'."
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Skill MQTT_SUBSCRIBE — s'abonner dynamiquement à un topic MQTT.
|
||||
|
||||
Les messages reçus sont transmis via XMPP (admin) et loggés.
|
||||
|
||||
Usage LLM :
|
||||
SKILL:mqtt_subscribe ARGS:subscribe | <topic>
|
||||
SKILL:mqtt_subscribe ARGS:unsubscribe | <topic>
|
||||
SKILL:mqtt_subscribe ARGS:list
|
||||
"""
|
||||
import logging
|
||||
|
||||
DESCRIPTION = "S'abonner / se désabonner dynamiquement d'un topic MQTT et recevoir les messages"
|
||||
USAGE = "SKILL:mqtt_subscribe ARGS:subscribe|<topic> ou unsubscribe|<topic> ou list"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Stockage des souscriptions dynamiques : {topic: callback}
|
||||
_dynamic_subs: dict = {}
|
||||
|
||||
|
||||
def run(args: str, context) -> str:
|
||||
parts = [p.strip() for p in args.split("|", 1)]
|
||||
action = parts[0].lower()
|
||||
|
||||
if action == "list":
|
||||
if not _dynamic_subs:
|
||||
return "Aucun topic MQTT surveillé."
|
||||
return "Topics surveillés :\n" + "\n".join(f" • {t}" for t in _dynamic_subs)
|
||||
|
||||
if len(parts) < 2 or not parts[1]:
|
||||
return "Format : subscribe|<topic> ou unsubscribe|<topic> ou list"
|
||||
|
||||
topic = parts[1]
|
||||
|
||||
if action == "unsubscribe":
|
||||
if topic in _dynamic_subs:
|
||||
del _dynamic_subs[topic]
|
||||
return f"Désabonné du topic '{topic}'."
|
||||
return f"Pas abonné à '{topic}'."
|
||||
|
||||
if action == "subscribe":
|
||||
if topic in _dynamic_subs:
|
||||
return f"Déjà abonné à '{topic}'."
|
||||
|
||||
agent_id = context.agent_id
|
||||
|
||||
def _on_message(msg, t):
|
||||
payload = msg.payload if hasattr(msg, "payload") else str(msg)
|
||||
text = f"[MQTT:{t}] {payload}"
|
||||
logger.info(f"[mqtt_subscribe] {text}")
|
||||
if context.xmpp:
|
||||
context.xmpp.send_to_all_admins(text)
|
||||
|
||||
_dynamic_subs[topic] = _on_message
|
||||
context.mqtt.subscribe(topic, _on_message)
|
||||
return f"Abonné au topic '{topic}'. Les messages seront transmis via XMPP."
|
||||
|
||||
return f"Action inconnue '{action}'. Utilise : subscribe, unsubscribe, list."
|
||||
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
Skill MUC_SEND — envoyer un message dans le groupe XMPP des agents.
|
||||
|
||||
Le groupe est agents@muc.xmpp.ovh (configuré dans config.json).
|
||||
|
||||
Usage LLM : SKILL:muc_send ARGS:<message>
|
||||
"""
|
||||
DESCRIPTION = "Envoyer un message dans le groupe XMPP des agents (MUC)"
|
||||
USAGE = "SKILL:muc_send ARGS:<message à envoyer dans le groupe>"
|
||||
|
||||
|
||||
def run(args: str, context) -> str:
|
||||
message = args.strip()
|
||||
if not message:
|
||||
return "Message vide."
|
||||
|
||||
if not context.xmpp:
|
||||
return "XMPP non configuré sur cet agent."
|
||||
|
||||
if not context.xmpp.muc_room:
|
||||
return "Aucun groupe MUC configuré."
|
||||
|
||||
context.xmpp.send_to_group(message)
|
||||
return f"Message envoyé dans le groupe {context.xmpp.muc_room}."
|
||||
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
Skill SCRIPT — bibliothèque de scripts bash par agent.
|
||||
|
||||
Chaque agent dispose de son propre dossier scripts/ (configurable via
|
||||
"scripts_dir" dans config.json, sinon /opt/<install_dir>/scripts).
|
||||
|
||||
L'environnement du script expose automatiquement :
|
||||
MQTT_BROKER, MQTT_PORT, MQTT_REPLY_TOPIC, AGENT_ID, SCRIPTS_DIR
|
||||
|
||||
Ainsi un script peut publier son résultat directement :
|
||||
mosquitto_pub -h $MQTT_BROKER -t $MQTT_REPLY_TOPIC -m "mon résultat"
|
||||
|
||||
Usage LLM :
|
||||
SKILL:script ARGS:list
|
||||
SKILL:script ARGS:show <nom>
|
||||
SKILL:script ARGS:save <nom> | <contenu>
|
||||
SKILL:script ARGS:edit <nom> <ligne> | <nouveau contenu de ligne>
|
||||
SKILL:script ARGS:exec <nom> [args...]
|
||||
SKILL:script ARGS:run | <contenu inline>
|
||||
SKILL:script ARGS:delete <nom>
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
|
||||
DESCRIPTION = "Bibliothèque de scripts bash : sauvegarder, lister, afficher, éditer, exécuter"
|
||||
USAGE = (
|
||||
"SKILL:script ARGS:list\n"
|
||||
"SKILL:script ARGS:show <nom>\n"
|
||||
"SKILL:script ARGS:save <nom> | <contenu>\n"
|
||||
"SKILL:script ARGS:edit <nom> <ligne> | <nouveau contenu>\n"
|
||||
"SKILL:script ARGS:exec <nom> [args]\n"
|
||||
"SKILL:script ARGS:run | <contenu inline>\n"
|
||||
"SKILL:script ARGS:delete <nom>"
|
||||
)
|
||||
|
||||
|
||||
def _scripts_dir(context) -> str:
|
||||
"""Détermine le répertoire scripts de cet agent."""
|
||||
if context.config.get("scripts_dir"):
|
||||
return context.config["scripts_dir"]
|
||||
queue_db = context.config.get("queue_db", "")
|
||||
if queue_db:
|
||||
install = os.path.dirname(os.path.dirname(queue_db))
|
||||
return os.path.join(install, "scripts")
|
||||
return f"/opt/{context.agent_id}/scripts"
|
||||
|
||||
|
||||
def _ensure_dir(context) -> str:
|
||||
d = _scripts_dir(context)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
_FORBIDDEN_EXTENSIONS = {".service", ".timer", ".socket", ".target", ".mount", ".conf", ".py", ".js"}
|
||||
|
||||
|
||||
def _safe_name(name: str) -> str:
|
||||
"""Empêche les traversées de répertoire et normalise le nom."""
|
||||
n = os.path.basename(name.strip().replace("/", "_"))
|
||||
# Retire toute extension connue pour obtenir le nom brut
|
||||
root, ext = os.path.splitext(n)
|
||||
while ext:
|
||||
n = root
|
||||
root, ext = os.path.splitext(n)
|
||||
return n
|
||||
|
||||
|
||||
def _build_env(context, scripts_dir: str) -> dict:
|
||||
env = os.environ.copy()
|
||||
mc = context.config.get("mqtt", {})
|
||||
env["MQTT_BROKER"] = mc.get("host", "localhost")
|
||||
env["MQTT_PORT"] = str(mc.get("port", 1883))
|
||||
env["MQTT_REPLY_TOPIC"] = "agents/nexus/inbox"
|
||||
env["AGENT_ID"] = context.agent_id
|
||||
env["SCRIPTS_DIR"] = scripts_dir
|
||||
return env
|
||||
|
||||
|
||||
def _notify(context, script_name: str, result: str):
|
||||
"""Publie un événement d'exécution sur MQTT pour que Nexus notifie l'utilisateur."""
|
||||
try:
|
||||
context.mqtt.publish_raw("agents/scripts/execution", json.dumps({
|
||||
"agent_id": context.agent_id,
|
||||
"script": script_name,
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"result": result[:1000],
|
||||
}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _run_script(cmd: str, env: dict, timeout: int = 120) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, shell=True, text=True,
|
||||
capture_output=True, timeout=timeout,
|
||||
env=env, executable="/bin/bash",
|
||||
)
|
||||
out = (result.stdout + result.stderr).strip()
|
||||
if len(out) > 4000:
|
||||
out = out[:4000] + "\n... [tronqué]"
|
||||
return out or f"(code retour : {result.returncode})"
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"Timeout ({timeout}s dépassé)"
|
||||
except Exception as e:
|
||||
return str(e)
|
||||
|
||||
|
||||
def run(args: str, context) -> str:
|
||||
parts = args.strip().split(None, 1)
|
||||
action = parts[0].lower() if parts else "list"
|
||||
rest = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
# ── list ──────────────────────────────────────────────────────────────
|
||||
if action == "list":
|
||||
d = _ensure_dir(context)
|
||||
files = sorted(f for f in os.listdir(d) if f.endswith(".sh"))
|
||||
if not files:
|
||||
return f"Aucun script dans {d}"
|
||||
lines = [f"Scripts disponibles ({d}) :"]
|
||||
for f in files:
|
||||
path = os.path.join(d, f)
|
||||
size = os.path.getsize(path)
|
||||
lines.append(f" {f[:-3]:30s} ({size} octets)")
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── show ──────────────────────────────────────────────────────────────
|
||||
if action == "show":
|
||||
name = _safe_name(rest)
|
||||
if not name:
|
||||
return "Précise le nom du script."
|
||||
d = _ensure_dir(context)
|
||||
path = os.path.join(d, name + ".sh")
|
||||
if not os.path.exists(path):
|
||||
return f"Script '{name}' introuvable dans {d}"
|
||||
with open(path) as f:
|
||||
content = f.read()
|
||||
return f"── {name}.sh ──\n{content}"
|
||||
|
||||
# ── save ──────────────────────────────────────────────────────────────
|
||||
if action == "save":
|
||||
if "|" not in rest:
|
||||
return "Format : save <nom> | <contenu du script>"
|
||||
name_raw, content = rest.split("|", 1)
|
||||
name = _safe_name(name_raw)
|
||||
content = content.strip().replace("\\n", "\n").replace('\\"', '"').replace("\\'", "'")
|
||||
|
||||
if not name:
|
||||
return "Nom de script invalide."
|
||||
|
||||
# Vérifie extension interdite sur le nom brut
|
||||
_, raw_ext = os.path.splitext(name_raw.strip())
|
||||
if raw_ext.lower() in _FORBIDDEN_EXTENSIONS:
|
||||
return f"Extension '{raw_ext}' interdite. Utilise un nom sans extension (ex: mon_script)."
|
||||
|
||||
# Vérifie que le contenu est substantiel (pas juste un shebang ou vide)
|
||||
lines = [l.strip() for l in content.splitlines() if l.strip() and not l.strip().startswith("#")]
|
||||
if len(lines) < 1:
|
||||
return "Contenu du script vide ou incomplet. Fournis au moins une commande."
|
||||
|
||||
d = _ensure_dir(context)
|
||||
path = os.path.join(d, name + ".sh")
|
||||
existed = os.path.exists(path)
|
||||
with open(path, "w") as f:
|
||||
if not content.startswith("#!"):
|
||||
f.write("#!/bin/bash\n")
|
||||
f.write(content + "\n")
|
||||
os.chmod(path, stat.S_IRWXU | stat.S_IRGRP | stat.S_IROTH)
|
||||
verb = "mis à jour" if existed else "créé"
|
||||
return f"Script '{name}' {verb} : {path}"
|
||||
|
||||
# ── edit ──────────────────────────────────────────────────────────────
|
||||
if action == "edit":
|
||||
# Format : edit <nom> <numéro_ligne> | <nouveau contenu de ligne>
|
||||
if "|" not in rest:
|
||||
return "Format : edit <nom> <ligne> | <nouveau contenu>\nEx: edit mon_script 3 | echo 'nouveau'"
|
||||
head, new_line_content = rest.split("|", 1)
|
||||
head_parts = head.strip().split(None, 1)
|
||||
if len(head_parts) < 2:
|
||||
return "Format : edit <nom> <ligne> | <nouveau contenu>"
|
||||
name = _safe_name(head_parts[0])
|
||||
try:
|
||||
line_no = int(head_parts[1].strip())
|
||||
except ValueError:
|
||||
return "Le numéro de ligne doit être un entier."
|
||||
if line_no < 1:
|
||||
return "Le numéro de ligne doit être >= 1."
|
||||
d = _ensure_dir(context)
|
||||
path = os.path.join(d, name + ".sh")
|
||||
if not os.path.exists(path):
|
||||
return f"Script '{name}' introuvable dans {d}"
|
||||
with open(path) as f:
|
||||
lines = f.readlines()
|
||||
if line_no > len(lines):
|
||||
return f"Le script '{name}' n'a que {len(lines)} lignes."
|
||||
lines[line_no - 1] = new_line_content.strip() + "\n"
|
||||
with open(path, "w") as f:
|
||||
f.writelines(lines)
|
||||
return f"Ligne {line_no} du script '{name}' modifiée.\nNouveau contenu :\n{''.join(lines)}"
|
||||
|
||||
# ── exec ──────────────────────────────────────────────────────────────
|
||||
if action == "exec":
|
||||
parts2 = rest.split(None, 1)
|
||||
name = _safe_name(parts2[0]) if parts2 else ""
|
||||
sargs = parts2[1] if len(parts2) > 1 else ""
|
||||
if not name:
|
||||
return "Précise le nom du script."
|
||||
d = _ensure_dir(context)
|
||||
path = os.path.join(d, name + ".sh")
|
||||
if not os.path.exists(path):
|
||||
return f"Script '{name}' introuvable. Utilise 'list' pour voir les scripts disponibles."
|
||||
env = _build_env(context, d)
|
||||
out = _run_script(f'"{path}" {sargs}', env=env, timeout=120)
|
||||
_notify(context, name, out)
|
||||
return out
|
||||
|
||||
# ── run (inline) ──────────────────────────────────────────────────────
|
||||
if action == "run":
|
||||
if not rest:
|
||||
return "Précise le contenu du script."
|
||||
d = _ensure_dir(context)
|
||||
content = rest.replace("\\n", "\n")
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".sh", delete=False, dir="/tmp"
|
||||
) as f:
|
||||
f.write("#!/bin/bash\nset -e\n" + content)
|
||||
tmpfile = f.name
|
||||
os.chmod(tmpfile, stat.S_IRWXU)
|
||||
env = _build_env(context, d)
|
||||
out = _run_script(tmpfile, env=env, timeout=60)
|
||||
os.unlink(tmpfile)
|
||||
_notify(context, "<inline>", out)
|
||||
return out
|
||||
|
||||
# ── delete ────────────────────────────────────────────────────────────
|
||||
if action == "delete":
|
||||
name = _safe_name(rest)
|
||||
if not name:
|
||||
return "Précise le nom du script."
|
||||
d = _ensure_dir(context)
|
||||
path = os.path.join(d, name + ".sh")
|
||||
if not os.path.exists(path):
|
||||
return f"Script '{name}' introuvable dans {d}"
|
||||
os.unlink(path)
|
||||
return f"Script '{name}' supprimé."
|
||||
|
||||
return "Action inconnue. Disponible : list, show, save, exec, run, delete"
|
||||
Reference in New Issue
Block a user