Update auf Skills
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import json, os, re, subprocess, sys
|
||||
from pathlib import Path
|
||||
PROTO='jarvis.skill.invoke.v1'
|
||||
NAMES=('compose.yaml','compose.yml','docker-compose.yaml','docker-compose.yml')
|
||||
def fail(c,m): return {'protocol':PROTO,'success':False,'error':{'code':c,'message':m}}
|
||||
def ok(d,m='OK',mut=False): return {'protocol':PROTO,'success':True,'data':d,'message':m,'mutated':mut}
|
||||
def root():
|
||||
p=Path(os.getenv('DOCKGE_STACKS_DIR','/opt/stacks')).resolve()
|
||||
if not p.exists() or not p.is_dir(): raise RuntimeError(f'Dockge-Stacks-Verzeichnis fehlt: {p}')
|
||||
return p
|
||||
def stack_dir(name):
|
||||
if not re.fullmatch(r'[A-Za-z0-9_.-]+',name or ''): raise RuntimeError('Ungültiger Stack-Name')
|
||||
p=(root()/name).resolve(); r=root()
|
||||
if p.parent!=r or not p.is_dir(): raise RuntimeError('Stack nicht gefunden')
|
||||
if not any((p/n).is_file() for n in NAMES): raise RuntimeError('Keine Compose-Datei im Stack gefunden')
|
||||
return p
|
||||
def allowed(name):
|
||||
vals=[x.strip() for x in os.getenv('DOCKGE_ALLOWED_STACKS','').split(',') if x.strip()]
|
||||
return '*' in vals or name in vals
|
||||
def require_allowed(name):
|
||||
if not allowed(name): raise RuntimeError('Mutation verweigert: Stack ist nicht in DOCKGE_ALLOWED_STACKS freigegeben')
|
||||
def compose_cmd():
|
||||
# Prefer modern plugin; fall back to legacy docker-compose.
|
||||
try:
|
||||
subprocess.run(['docker','compose','version'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,check=True,timeout=5)
|
||||
return ['docker','compose']
|
||||
except Exception:
|
||||
return ['docker-compose']
|
||||
def run(name,args,timeout=90):
|
||||
p=stack_dir(name); cmd=compose_cmd()+args
|
||||
cp=subprocess.run(cmd,cwd=p,text=True,capture_output=True,timeout=timeout)
|
||||
if cp.returncode!=0: raise RuntimeError((cp.stderr or cp.stdout or f'Exit {cp.returncode}')[-1800:])
|
||||
return (cp.stdout or '').strip()
|
||||
def main(inv):
|
||||
a=inv.get('action'); x=inv.get('input') or {}
|
||||
if a=='list_stacks':
|
||||
out=[]
|
||||
for p in sorted(root().iterdir()):
|
||||
if not p.is_dir(): continue
|
||||
cf=next((n for n in NAMES if (p/n).is_file()),None)
|
||||
if cf: out.append({'name':p.name,'compose_file':cf,'mutation_allowed':allowed(p.name)})
|
||||
return ok({'stacks':out,'count':len(out)},'Dockge-Stacks geladen.')
|
||||
name=x['stack']
|
||||
if a=='stack_status':
|
||||
raw=run(name,['ps','--format','json'],30); items=[]
|
||||
for line in raw.splitlines():
|
||||
try:
|
||||
v=json.loads(line)
|
||||
items.extend(v if isinstance(v,list) else [v])
|
||||
except Exception: pass
|
||||
return ok({'stack':name,'services':items,'count':len(items)},'Stack-Status geladen.')
|
||||
require_allowed(name)
|
||||
if a=='start_stack': run(name,['up','-d','--remove-orphans']); return ok({'stack':name,'action':'start'},'Stack gestartet.',True)
|
||||
if a=='stop_stack': run(name,['stop']); return ok({'stack':name,'action':'stop'},'Stack gestoppt.',True)
|
||||
if a=='restart_stack': run(name,['restart']); return ok({'stack':name,'action':'restart'},'Stack neu gestartet.',True)
|
||||
if a=='update_stack':
|
||||
run(name,['pull'],120); run(name,['up','-d','--remove-orphans'],120); return ok({'stack':name,'action':'update'},'Stack aktualisiert.',True)
|
||||
return fail('UNKNOWN_ACTION',f'Unbekannte Aktion: {a}')
|
||||
try: res=main(json.load(sys.stdin))
|
||||
except subprocess.TimeoutExpired: res=fail('DOCKGE_TIMEOUT','Docker-Compose-Aktion hat das Zeitlimit überschritten')
|
||||
except Exception as e: res=fail('DOCKGE_ERROR',str(e))
|
||||
json.dump(res,sys.stdout,ensure_ascii=False)
|
||||
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"protocol": "jarvis.skill.v1",
|
||||
"id": "dockge.compose",
|
||||
"name": "Dockge Stack Control",
|
||||
"version": "1.0.0",
|
||||
"description": "Steuert Dockge-verwaltete Compose-Stacks direkt über deren Stack-Verzeichnis und Docker Compose. Nutzt bewusst nicht Dockges instabile interne Socket-API.",
|
||||
"runtime": {
|
||||
"type": "process",
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"main.py"
|
||||
],
|
||||
"timeout_ms": 120000,
|
||||
"env_from": [
|
||||
"DOCKGE_STACKS_DIR",
|
||||
"DOCKGE_ALLOWED_STACKS"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"system_exec": true
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"name": "list_stacks",
|
||||
"description": "Listet Dockge-Stack-Verzeichnisse und deren Compose-Dateien.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stacks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"stacks",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "stack_status",
|
||||
"description": "Liest den Docker-Compose-Status eines Dockge-Stacks.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stack": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"stack"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "start_stack",
|
||||
"description": "Startet/deployed einen erlaubten Dockge-Stack mit docker compose up -d.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stack": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"stack"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "stop_stack",
|
||||
"description": "Stoppt einen erlaubten Dockge-Stack.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stack": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"stack"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "restart_stack",
|
||||
"description": "Startet einen erlaubten Dockge-Stack neu.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stack": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"stack"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "update_stack",
|
||||
"description": "Pullt Images eines erlaubten Dockge-Stacks und deployed ihn anschließend neu.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stack": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"stack"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
import json, sys
|
||||
req=json.load(sys.stdin)
|
||||
text=str(req.get("input",{}).get("text",""))
|
||||
json.dump({"protocol":"jarvis.skill.invoke.v1","success":True,"data":{"text":text.upper()},"message":"Text verarbeitet."},sys.stdout)
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"protocol": "jarvis.skill.v1",
|
||||
"id": "example.python.text",
|
||||
"name": "Python Text Example",
|
||||
"version": "1.0.0",
|
||||
"description": "Beispiel für einen Remote-Python-Skill im Skill Mesh.",
|
||||
"runtime": {
|
||||
"type": "process",
|
||||
"command": "python3",
|
||||
"args": ["main.py"],
|
||||
"timeout_ms": 5000
|
||||
},
|
||||
"permissions": {"system_exec": true},
|
||||
"actions": [{
|
||||
"name": "uppercase",
|
||||
"description": "Wandelt Text in Großbuchstaben um.",
|
||||
"triggers": ["großbuchstaben", "uppercase"],
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"text": {"type": "string"}},
|
||||
"required": ["text"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {"text": {"type": "string"}},
|
||||
"required": ["text"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import json, os, socket, ssl, sys, time
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError
|
||||
PROTO='jarvis.skill.invoke.v1'
|
||||
def fail(c,m): return {'protocol':PROTO,'success':False,'error':{'code':c,'message':m}}
|
||||
def ok(d,m='OK',mut=False): return {'protocol':PROTO,'success':True,'data':d,'message':m,'mutated':mut}
|
||||
def main(inv):
|
||||
a=inv.get('action'); x=inv.get('input') or {}
|
||||
if a=='tcp_check':
|
||||
t=time.perf_counter(); reachable=True
|
||||
try:
|
||||
with socket.create_connection((x['host'],int(x['port'])),timeout=float(x.get('timeout_seconds',3))): pass
|
||||
except OSError: reachable=False
|
||||
ms=(time.perf_counter()-t)*1000; return ok({'reachable':reachable,'latency_ms':round(ms,2)},'TCP-Prüfung abgeschlossen.')
|
||||
if a=='http_check':
|
||||
t=time.perf_counter(); status=0; reachable=False; verify=bool(x.get('verify_tls',True)); ctx=None
|
||||
if x['url'].startswith('https://') and not verify: ctx=ssl._create_unverified_context()
|
||||
try:
|
||||
with urlopen(Request(x['url'],headers={'User-Agent':'JARVIS-Skill/1'}),timeout=float(x.get('timeout_seconds',5)),context=ctx) as r: status=int(r.status); reachable=True
|
||||
except HTTPError as e: status=int(e.code); reachable=True
|
||||
except Exception: pass
|
||||
ms=(time.perf_counter()-t)*1000; return ok({'reachable':reachable,'status':status,'latency_ms':round(ms,2)},'HTTP-Prüfung abgeschlossen.')
|
||||
if a=='dns_lookup':
|
||||
vals=sorted({i[4][0] for i in socket.getaddrinfo(x['host'],None)}); return ok({'addresses':vals,'count':len(vals)},'DNS aufgelöst.')
|
||||
if a=='wake_on_lan':
|
||||
mac=''.join(c for c in x['mac'] if c.isalnum())
|
||||
if len(mac)!=12: return fail('INVALID_MAC','MAC-Adresse ungültig')
|
||||
packet=b'\xff'*6+bytes.fromhex(mac)*16; broad=x.get('broadcast') or os.getenv('WOL_BROADCAST','255.255.255.255'); port=int(x.get('port') or os.getenv('WOL_PORT','9'))
|
||||
s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.setsockopt(socket.SOL_SOCKET,socket.SO_BROADCAST,1); s.sendto(packet,(broad,port)); s.close()
|
||||
return ok({'sent':True,'broadcast':broad,'port':port},'Wake-on-LAN gesendet.',True)
|
||||
return fail('UNKNOWN_ACTION',f'Unbekannte Aktion: {a}')
|
||||
try: res=main(json.load(sys.stdin))
|
||||
except Exception as e: res=fail('NETWORK_TOOL_ERROR',str(e))
|
||||
json.dump(res,sys.stdout,ensure_ascii=False)
|
||||
@@ -0,0 +1,184 @@
|
||||
{
|
||||
"protocol": "jarvis.skill.v1",
|
||||
"id": "home.network-tools",
|
||||
"name": "Home Network Tools",
|
||||
"version": "1.0.0",
|
||||
"description": "Kleine Netzwerk-Werkzeuge für Erreichbarkeit, DNS und Wake-on-LAN.",
|
||||
"runtime": {
|
||||
"type": "process",
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"main.py"
|
||||
],
|
||||
"timeout_ms": 10000,
|
||||
"env_from": [
|
||||
"WOL_BROADCAST",
|
||||
"WOL_PORT"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"network": true,
|
||||
"system_exec": true
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"name": "tcp_check",
|
||||
"description": "Prüft, ob ein TCP-Port erreichbar ist.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer"
|
||||
},
|
||||
"timeout_seconds": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"host",
|
||||
"port"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reachable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"latency_ms": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"reachable",
|
||||
"latency_ms"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "http_check",
|
||||
"description": "Prüft einen HTTP/HTTPS-Endpunkt und misst die Antwortzeit.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
"timeout_seconds": {
|
||||
"type": "number"
|
||||
},
|
||||
"verify_tls": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"url"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reachable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"status": {
|
||||
"type": "integer"
|
||||
},
|
||||
"latency_ms": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"reachable",
|
||||
"status",
|
||||
"latency_ms"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "dns_lookup",
|
||||
"description": "Löst einen Hostnamen in IP-Adressen auf.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"host"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"addresses": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"addresses",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "wake_on_lan",
|
||||
"description": "Sendet ein Wake-on-LAN Magic Packet an ein Gerät.",
|
||||
"mutates": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mac": {
|
||||
"type": "string"
|
||||
},
|
||||
"broadcast": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"mac"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sent": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"broadcast": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sent",
|
||||
"broadcast",
|
||||
"port"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import json, os, ssl, sys
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError, URLError
|
||||
PROTO='jarvis.skill.invoke.v1'
|
||||
def truthy(v): return str(v or '').strip().lower() in ('1','true','yes','on')
|
||||
def fail(c,m): return {'protocol':PROTO,'success':False,'error':{'code':c,'message':m}}
|
||||
def ok(d,m='OK',mut=False): return {'protocol':PROTO,'success':True,'data':d,'message':m,'mutated':mut}
|
||||
def main(inv):
|
||||
x=inv.get('input') or {}; base=os.getenv('NTFY_BASE_URL','https://ntfy.sh').strip().rstrip('/'); topic=(x.get('topic') or os.getenv('NTFY_DEFAULT_TOPIC','')).strip()
|
||||
if not topic: return fail('CONFIG_MISSING','NTFY_DEFAULT_TOPIC oder input.topic fehlt')
|
||||
h={'Content-Type':'text/plain; charset=utf-8'}; token=os.getenv('NTFY_TOKEN','').strip()
|
||||
if token: h['Authorization']='Bearer '+token
|
||||
if x.get('title'): h['Title']=x['title']
|
||||
if x.get('priority'): h['Priority']=x['priority']
|
||||
if x.get('tags'): h['Tags']=x['tags']
|
||||
ctx=ssl._create_unverified_context() if base.startswith('https://') and not truthy(os.getenv('NTFY_VERIFY_TLS','true')) else None
|
||||
try:
|
||||
with urlopen(Request(base+'/'+topic,data=x['message'].encode(),headers=h,method='POST'),timeout=6,context=ctx) as r: r.read()
|
||||
except HTTPError as e: raise RuntimeError(f'ntfy HTTP {e.code}: {e.read().decode(errors="replace")[:500]}')
|
||||
except URLError as e: raise RuntimeError(f'ntfy nicht erreichbar: {e.reason}')
|
||||
return ok({'sent':True,'topic':topic},'Benachrichtigung gesendet.',True)
|
||||
try: res=main(json.load(sys.stdin))
|
||||
except Exception as e: res=fail('NTFY_ERROR',str(e))
|
||||
json.dump(res,sys.stdout,ensure_ascii=False)
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"protocol": "jarvis.skill.v1",
|
||||
"id": "notify.ntfy",
|
||||
"name": "ntfy Notifications",
|
||||
"version": "1.0.0",
|
||||
"description": "Sendet Benachrichtigungen an einen selbstgehosteten oder öffentlichen ntfy-Server.",
|
||||
"runtime": {
|
||||
"type": "process",
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"main.py"
|
||||
],
|
||||
"timeout_ms": 8000,
|
||||
"env_from": [
|
||||
"NTFY_BASE_URL",
|
||||
"NTFY_TOKEN",
|
||||
"NTFY_DEFAULT_TOPIC",
|
||||
"NTFY_VERIFY_TLS"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"network": true,
|
||||
"system_exec": true
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"name": "send",
|
||||
"description": "Sendet eine ntfy-Benachrichtigung.",
|
||||
"mutates": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"topic": {
|
||||
"type": "string"
|
||||
},
|
||||
"priority": {
|
||||
"type": "string"
|
||||
},
|
||||
"tags": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sent": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"topic": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sent",
|
||||
"topic"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import json, os, ssl, sys
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
PROTO="jarvis.skill.invoke.v1"
|
||||
def truthy(v): return str(v or '').strip().lower() in ('1','true','yes','on')
|
||||
def fail(code,msg): return {"protocol":PROTO,"success":False,"error":{"code":code,"message":msg}}
|
||||
def ok(data,msg="OK",mut=False): return {"protocol":PROTO,"success":True,"data":data,"message":msg,"mutated":mut}
|
||||
def config():
|
||||
base=os.getenv('HUE_BRIDGE_URL','').strip().rstrip('/')
|
||||
key=os.getenv('HUE_APP_KEY','').strip()
|
||||
if not base or not key: raise RuntimeError('HUE_BRIDGE_URL und HUE_APP_KEY müssen im Worker gesetzt sein')
|
||||
if not base.startswith(('http://','https://')): base='https://'+base
|
||||
return base,key,truthy(os.getenv('HUE_VERIFY_TLS','false'))
|
||||
def req(method,path,body=None):
|
||||
base,key,verify=config(); data=None
|
||||
headers={'Accept':'application/json','hue-application-key':key}
|
||||
if body is not None:
|
||||
data=json.dumps(body).encode(); headers['Content-Type']='application/json'
|
||||
ctx=None
|
||||
if base.startswith('https://') and not verify: ctx=ssl._create_unverified_context()
|
||||
r=Request(base+path,data=data,headers=headers,method=method)
|
||||
try:
|
||||
with urlopen(r,timeout=6,context=ctx) as resp:
|
||||
raw=resp.read(); return json.loads(raw.decode()) if raw else {}
|
||||
except HTTPError as e:
|
||||
detail=e.read().decode(errors='replace')[:1000]; raise RuntimeError(f'Hue HTTP {e.code}: {detail}')
|
||||
except URLError as e: raise RuntimeError(f'Hue nicht erreichbar: {e.reason}')
|
||||
def data(path):
|
||||
raw=req('GET',path); return raw.get('data',[]) if isinstance(raw,dict) else []
|
||||
def n(v):
|
||||
if isinstance(v,dict): return v.get('name') or v.get('value') or ''
|
||||
return v or ''
|
||||
def main(inv):
|
||||
a=inv.get('action'); x=inv.get('input') or {}
|
||||
if a=='list_lights':
|
||||
out=[]
|
||||
for v in data('/clip/v2/resource/light'):
|
||||
out.append({'id':v.get('id',''),'name':n(v.get('metadata')),'on':(v.get('on') or {}).get('on'), 'brightness':(v.get('dimming') or {}).get('brightness'),'owner_id':(v.get('owner') or {}).get('rid','')})
|
||||
return ok({'lights':out,'count':len(out)},'Hue-Lampen geladen.')
|
||||
if a=='list_rooms':
|
||||
out=[]
|
||||
for v in data('/clip/v2/resource/room'):
|
||||
gid=''
|
||||
for s in v.get('services') or []:
|
||||
if s.get('rtype')=='grouped_light': gid=s.get('rid',''); break
|
||||
out.append({'id':v.get('id',''),'name':n(v.get('metadata')),'grouped_light_id':gid})
|
||||
return ok({'rooms':out,'count':len(out)},'Hue-Räume geladen.')
|
||||
if a=='list_scenes':
|
||||
out=[]
|
||||
for v in data('/clip/v2/resource/scene'):
|
||||
out.append({'id':v.get('id',''),'name':n(v.get('metadata')),'group_id':(v.get('group') or {}).get('rid','')})
|
||||
return ok({'scenes':out,'count':len(out)},'Hue-Szenen geladen.')
|
||||
if a in ('set_light','set_room'):
|
||||
ident=x['light_id'] if a=='set_light' else x['grouped_light_id']
|
||||
body={}
|
||||
if 'on' in x: body['on']={'on':bool(x['on'])}
|
||||
if 'brightness' in x: body['dimming']={'brightness':max(0.0,min(100.0,float(x['brightness'])))}
|
||||
if a=='set_light' and 'x' in x and 'y' in x: body['color']={'xy':{'x':float(x['x']),'y':float(x['y'])}}
|
||||
if not body: return fail('INVALID_INPUT','Mindestens on, brightness oder x/y angeben')
|
||||
typ='light' if a=='set_light' else 'grouped_light'
|
||||
req('PUT',f'/clip/v2/resource/{typ}/{ident}',body)
|
||||
return ok({'id':ident,'changed':True},'Hue-Zustand geändert.',True)
|
||||
if a=='activate_scene':
|
||||
ident=x['scene_id']; req('PUT',f'/clip/v2/resource/scene/{ident}',{'recall':{'action':'active'}})
|
||||
return ok({'id':ident,'activated':True},'Hue-Szene aktiviert.',True)
|
||||
return fail('UNKNOWN_ACTION',f'Unbekannte Aktion: {a}')
|
||||
try:
|
||||
inv=json.load(sys.stdin); res=main(inv)
|
||||
except Exception as e: res=fail('HUE_ERROR',str(e))
|
||||
json.dump(res,sys.stdout,ensure_ascii=False)
|
||||
@@ -0,0 +1,232 @@
|
||||
{
|
||||
"protocol": "jarvis.skill.v1",
|
||||
"id": "philips.hue",
|
||||
"name": "Philips Hue",
|
||||
"version": "1.0.0",
|
||||
"description": "Lokale Philips-Hue-Bridge-Steuerung über die CLIP API v2.",
|
||||
"runtime": {
|
||||
"type": "process",
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"main.py"
|
||||
],
|
||||
"timeout_ms": 8000,
|
||||
"env_from": [
|
||||
"HUE_BRIDGE_URL",
|
||||
"HUE_APP_KEY",
|
||||
"HUE_VERIFY_TLS"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"network": true,
|
||||
"system_exec": true
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"name": "list_lights",
|
||||
"description": "Listet Hue-Lampen mit ID, Name, Ein/Aus und Helligkeit.",
|
||||
"triggers": [
|
||||
"hue lampen",
|
||||
"lichter",
|
||||
"beleuchtung"
|
||||
],
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"lights": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"lights",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_rooms",
|
||||
"description": "Listet Hue-Räume und die zugehörige grouped_light-ID zur Raumsteuerung.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rooms": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"rooms",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_scenes",
|
||||
"description": "Listet Hue-Szenen mit ID und Name.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scenes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"scenes",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "set_light",
|
||||
"description": "Schaltet eine Hue-Lampe oder setzt Helligkeit/Farbe.",
|
||||
"mutates": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"light_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"on": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"brightness": {
|
||||
"type": "number"
|
||||
},
|
||||
"x": {
|
||||
"type": "number"
|
||||
},
|
||||
"y": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"light_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"changed": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"changed"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "set_room",
|
||||
"description": "Schaltet einen Hue-Raum über seine grouped_light-ID oder setzt die Helligkeit.",
|
||||
"mutates": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"grouped_light_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"on": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"brightness": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"grouped_light_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"changed": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"changed"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "activate_scene",
|
||||
"description": "Aktiviert eine vorhandene Hue-Szene anhand ihrer ID.",
|
||||
"mutates": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scene_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"scene_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"activated": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"activated"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import json, os, ssl, sys
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError, URLError
|
||||
PROTO='jarvis.skill.invoke.v1'
|
||||
def truthy(v): return str(v or '').strip().lower() in ('1','true','yes','on')
|
||||
def fail(c,m): return {'protocol':PROTO,'success':False,'error':{'code':c,'message':m}}
|
||||
def ok(d,m='OK',mut=False): return {'protocol':PROTO,'success':True,'data':d,'message':m,'mutated':mut}
|
||||
def cfg():
|
||||
b=os.getenv('PROXMOX_BASE_URL','').strip().rstrip('/'); tid=os.getenv('PROXMOX_TOKEN_ID','').strip(); sec=os.getenv('PROXMOX_TOKEN_SECRET','').strip()
|
||||
if not b or not tid or not sec: raise RuntimeError('PROXMOX_BASE_URL, PROXMOX_TOKEN_ID und PROXMOX_TOKEN_SECRET müssen gesetzt sein')
|
||||
if not b.startswith(('http://','https://')): b='https://'+b
|
||||
return b,tid,sec,truthy(os.getenv('PROXMOX_VERIFY_TLS','false'))
|
||||
def request(method,path,form=None,query=None):
|
||||
b,tid,sec,verify=cfg(); url=b+'/api2/json'+path
|
||||
if query: url+='?'+urlencode({k:v for k,v in query.items() if v not in (None,'')})
|
||||
data=None; h={'Accept':'application/json','Authorization':f'PVEAPIToken={tid}={sec}'}
|
||||
if form is not None: data=urlencode(form).encode(); h['Content-Type']='application/x-www-form-urlencoded'
|
||||
elif method=='POST': data=b''
|
||||
ctx=ssl._create_unverified_context() if url.startswith('https://') and not verify else None
|
||||
try:
|
||||
with urlopen(Request(url,data=data,headers=h,method=method),timeout=12,context=ctx) as r:
|
||||
raw=r.read(); obj=json.loads(raw.decode()) if raw else {}; return obj.get('data') if isinstance(obj,dict) and 'data' in obj else obj
|
||||
except HTTPError as e: raise RuntimeError(f'Proxmox HTTP {e.code}: {e.read().decode(errors="replace")[:1000]}')
|
||||
except URLError as e: raise RuntimeError(f'Proxmox nicht erreichbar: {e.reason}')
|
||||
def guest_path(x):
|
||||
typ=x['guest_type'];
|
||||
if typ not in ('qemu','lxc'): raise RuntimeError('guest_type muss qemu oder lxc sein')
|
||||
return f"/nodes/{x['node']}/{typ}/{int(x['vmid'])}"
|
||||
def main(inv):
|
||||
a=inv.get('action'); x=inv.get('input') or {}
|
||||
if a=='version': return ok(request('GET','/version'),'Proxmox API erreichbar.')
|
||||
if a=='list_nodes':
|
||||
vals=request('GET','/nodes') or []; out=[{'node':v.get('node',''),'status':v.get('status',''),'cpu':v.get('cpu'),'maxcpu':v.get('maxcpu'),'mem':v.get('mem'),'maxmem':v.get('maxmem'),'uptime':v.get('uptime')} for v in vals]
|
||||
return ok({'nodes':out,'count':len(out)},'Proxmox-Nodes geladen.')
|
||||
if a=='list_guests':
|
||||
vals=request('GET','/cluster/resources',query={'type':'vm'}) or []; out=[]
|
||||
for v in vals:
|
||||
typ=v.get('type','')
|
||||
if typ not in ('qemu','lxc'): continue
|
||||
if x.get('node') and v.get('node')!=x['node']: continue
|
||||
if x.get('status') and v.get('status')!=x['status']: continue
|
||||
out.append({'vmid':v.get('vmid'),'name':v.get('name',''),'type':typ,'node':v.get('node',''),'status':v.get('status',''),'cpu':v.get('cpu'),'mem':v.get('mem'),'maxmem':v.get('maxmem'),'uptime':v.get('uptime')})
|
||||
return ok({'guests':out,'count':len(out)},'Proxmox-Guests geladen.')
|
||||
if a=='guest_status': return ok(request('GET',guest_path(x)+'/status/current') or {},'Guest-Status geladen.')
|
||||
if a in ('start_guest','shutdown_guest','reboot_guest'):
|
||||
action={'start_guest':'start','shutdown_guest':'shutdown','reboot_guest':'reboot'}[a]; data=request('POST',guest_path(x)+'/status/'+action)
|
||||
return ok({'upid':data or '', 'action':action},f'Guest-Aktion {action} ausgelöst.',True)
|
||||
if a=='snapshot_guest':
|
||||
form={'snapname':x['name']}
|
||||
if x.get('description'): form['description']=x['description']
|
||||
if x.get('include_ram'): form['vmstate']='1'
|
||||
data=request('POST',guest_path(x)+'/snapshot',form=form); return ok({'upid':data or '', 'snapshot':x['name']},'Snapshot angelegt.',True)
|
||||
return fail('UNKNOWN_ACTION',f'Unbekannte Aktion: {a}')
|
||||
try: res=main(json.load(sys.stdin))
|
||||
except Exception as e: res=fail('PROXMOX_ERROR',str(e))
|
||||
json.dump(res,sys.stdout,ensure_ascii=False)
|
||||
@@ -0,0 +1,275 @@
|
||||
{
|
||||
"protocol": "jarvis.skill.v1",
|
||||
"id": "proxmox.ve",
|
||||
"name": "Proxmox VE",
|
||||
"version": "1.0.0",
|
||||
"description": "Proxmox-VE-Cluster- und Guest-Steuerung über die REST API.",
|
||||
"runtime": {
|
||||
"type": "process",
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"main.py"
|
||||
],
|
||||
"timeout_ms": 15000,
|
||||
"env_from": [
|
||||
"PROXMOX_BASE_URL",
|
||||
"PROXMOX_TOKEN_ID",
|
||||
"PROXMOX_TOKEN_SECRET",
|
||||
"PROXMOX_VERIFY_TLS"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"network": true,
|
||||
"system_exec": true
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"name": "version",
|
||||
"description": "Prüft die Proxmox-API und liefert die Version.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_nodes",
|
||||
"description": "Listet Proxmox-Nodes samt Status und Ressourcen.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"nodes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"nodes",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_guests",
|
||||
"description": "Listet QEMU-VMs und LXC-Container clusterweit.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"guests": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"guests",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "guest_status",
|
||||
"description": "Liest den aktuellen Status einer QEMU-VM oder eines LXC-Containers.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {
|
||||
"type": "string"
|
||||
},
|
||||
"guest_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"qemu",
|
||||
"lxc"
|
||||
]
|
||||
},
|
||||
"vmid": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"node",
|
||||
"guest_type",
|
||||
"vmid"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "start_guest",
|
||||
"description": "Startet eine QEMU-VM oder einen LXC-Container.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {
|
||||
"type": "string"
|
||||
},
|
||||
"guest_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"qemu",
|
||||
"lxc"
|
||||
]
|
||||
},
|
||||
"vmid": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"node",
|
||||
"guest_type",
|
||||
"vmid"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "shutdown_guest",
|
||||
"description": "Fährt eine QEMU-VM oder einen LXC-Container sauber herunter.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {
|
||||
"type": "string"
|
||||
},
|
||||
"guest_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"qemu",
|
||||
"lxc"
|
||||
]
|
||||
},
|
||||
"vmid": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"node",
|
||||
"guest_type",
|
||||
"vmid"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reboot_guest",
|
||||
"description": "Startet eine QEMU-VM oder einen LXC-Container neu.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {
|
||||
"type": "string"
|
||||
},
|
||||
"guest_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"qemu",
|
||||
"lxc"
|
||||
]
|
||||
},
|
||||
"vmid": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"node",
|
||||
"guest_type",
|
||||
"vmid"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "snapshot_guest",
|
||||
"description": "Erstellt einen Proxmox-Snapshot eines Guests.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {
|
||||
"type": "string"
|
||||
},
|
||||
"guest_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"qemu",
|
||||
"lxc"
|
||||
]
|
||||
},
|
||||
"vmid": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"include_ram": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"node",
|
||||
"guest_type",
|
||||
"vmid",
|
||||
"name"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import json, os, ssl, sys
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError, URLError
|
||||
PROTO='jarvis.skill.invoke.v1'
|
||||
def truthy(v): return str(v or '').strip().lower() in ('1','true','yes','on')
|
||||
def fail(c,m): return {'protocol':PROTO,'success':False,'error':{'code':c,'message':m}}
|
||||
def ok(d,m='OK',mut=False): return {'protocol':PROTO,'success':True,'data':d,'message':m,'mutated':mut}
|
||||
def cfg():
|
||||
b=os.getenv('UNIFI_NETWORK_URL','').strip().rstrip('/'); k=os.getenv('UNIFI_API_KEY','').strip()
|
||||
if not b or not k: raise RuntimeError('UNIFI_NETWORK_URL und UNIFI_API_KEY müssen gesetzt sein')
|
||||
if not b.startswith(('http://','https://')): b='https://'+b
|
||||
return b,k,truthy(os.getenv('UNIFI_VERIFY_TLS','false'))
|
||||
def request(method,path,body=None,query=None):
|
||||
b,k,verify=cfg(); url=b+path
|
||||
if query:
|
||||
q={a:v for a,v in query.items() if v not in (None,'')};
|
||||
if q: url+='?'+urlencode(q)
|
||||
data=None; h={'Accept':'application/json','X-API-Key':k}
|
||||
if body is not None: data=json.dumps(body).encode(); h['Content-Type']='application/json'
|
||||
ctx=None
|
||||
if url.startswith('https://') and not verify: ctx=ssl._create_unverified_context()
|
||||
try:
|
||||
with urlopen(Request(url,data=data,headers=h,method=method),timeout=8,context=ctx) as r:
|
||||
raw=r.read(); return json.loads(raw.decode()) if raw else {}
|
||||
except HTTPError as e: raise RuntimeError(f'UniFi Network HTTP {e.code}: {e.read().decode(errors="replace")[:1000]}')
|
||||
except URLError as e: raise RuntimeError(f'UniFi Network nicht erreichbar: {e.reason}')
|
||||
def items(raw):
|
||||
if isinstance(raw,list): return raw
|
||||
if isinstance(raw,dict):
|
||||
d=raw.get('data'); return d if isinstance(d,list) else []
|
||||
return []
|
||||
def normalize_site(v): return {'id':v.get('id') or v.get('siteId') or '', 'name':v.get('name') or (v.get('meta') or {}).get('name') or '', 'description':v.get('description') or (v.get('meta') or {}).get('desc') or ''}
|
||||
def normalize_device(v): return {'id':v.get('id',''),'name':v.get('name',''),'model':v.get('model',''),'state':v.get('state',''),'ip':v.get('ipAddress') or v.get('ip',''),'mac':v.get('macAddress') or v.get('mac',''),'firmware':v.get('firmwareVersion',''),'updatable':v.get('firmwareUpdatable')}
|
||||
def normalize_client(v):
|
||||
return {'id':v.get('id',''),'name':v.get('name') or v.get('hostname') or v.get('displayName') or '', 'type':v.get('type',''),'ip':v.get('ipAddress') or v.get('ip',''),'mac':v.get('macAddress') or v.get('mac',''),'connected_at':v.get('connectedAt'),'access':v.get('access')}
|
||||
def main(inv):
|
||||
a=inv.get('action'); x=inv.get('input') or {}
|
||||
if a=='info': return ok(request('GET','/v1/info'),'UniFi Network API erreichbar.')
|
||||
if a=='list_sites':
|
||||
vals=[normalize_site(v) for v in items(request('GET','/v1/sites'))]; return ok({'sites':vals,'count':len(vals)},'UniFi-Sites geladen.')
|
||||
if a in ('list_devices','list_clients'):
|
||||
limit=max(1,min(200,int(x.get('limit',50)))); q={'limit':limit,'filter':x.get('filter','')}
|
||||
path=f"/v1/sites/{x['site_id']}/"+('devices' if a=='list_devices' else 'clients')
|
||||
raw=items(request('GET',path,query=q)); vals=[(normalize_device(v) if a=='list_devices' else normalize_client(v)) for v in raw]
|
||||
key='devices' if a=='list_devices' else 'clients'; return ok({key:vals,'count':len(vals)},'UniFi-Daten geladen.')
|
||||
if a=='restart_device':
|
||||
request('POST',f"/v1/sites/{x['site_id']}/devices/{x['device_id']}/actions",{'action':'RESTART'}); return ok({'accepted':True},'Geräte-Neustart ausgelöst.',True)
|
||||
if a=='power_cycle_port':
|
||||
request('POST',f"/v1/sites/{x['site_id']}/devices/{x['device_id']}/interfaces/ports/{int(x['port_idx'])}/actions",{'action':'POWER_CYCLE'}); return ok({'accepted':True},'PoE Power-Cycle ausgelöst.',True)
|
||||
if a in ('authorize_guest','unauthorize_guest'):
|
||||
body={'action':'AUTHORIZE_GUEST_ACCESS' if a=='authorize_guest' else 'UNAUTHORIZE_GUEST_ACCESS'}
|
||||
if a=='authorize_guest' and x.get('time_limit_minutes'): body['timeLimitMinutes']=int(x['time_limit_minutes'])
|
||||
raw=request('POST',f"/v1/sites/{x['site_id']}/clients/{x['client_id']}/actions",body)
|
||||
return ok(raw if isinstance(raw,dict) else {'result':raw},'Gastzugang geändert.',True)
|
||||
return fail('UNKNOWN_ACTION',f'Unbekannte Aktion: {a}')
|
||||
try: res=main(json.load(sys.stdin))
|
||||
except Exception as e: res=fail('UNIFI_NETWORK_ERROR',str(e))
|
||||
json.dump(res,sys.stdout,ensure_ascii=False)
|
||||
@@ -0,0 +1,273 @@
|
||||
{
|
||||
"protocol": "jarvis.skill.v1",
|
||||
"id": "unifi.network",
|
||||
"name": "UniFi Network",
|
||||
"version": "1.0.0",
|
||||
"description": "Lokale UniFi-Network-Integration über die offizielle Integration API.",
|
||||
"runtime": {
|
||||
"type": "process",
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"main.py"
|
||||
],
|
||||
"timeout_ms": 10000,
|
||||
"env_from": [
|
||||
"UNIFI_NETWORK_URL",
|
||||
"UNIFI_API_KEY",
|
||||
"UNIFI_VERIFY_TLS"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"network": true,
|
||||
"system_exec": true
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"name": "info",
|
||||
"description": "Prüft die UniFi-Network-API und liefert Versionsinformationen.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_sites",
|
||||
"description": "Listet lokale UniFi-Network-Sites.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sites": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sites",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_devices",
|
||||
"description": "Listet adoptierte UniFi-Geräte einer Site.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"site_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"filter": {
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"site_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"devices": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"devices",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_clients",
|
||||
"description": "Listet verbundene Clients einer UniFi-Site; kann zur Anwesenheitserkennung genutzt werden.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"site_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"filter": {
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"site_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"clients": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"clients",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "restart_device",
|
||||
"description": "Startet ein adoptiertes UniFi-Gerät neu.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"site_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"device_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"site_id",
|
||||
"device_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accepted": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"accepted"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "power_cycle_port",
|
||||
"description": "Führt einen PoE Power-Cycle auf einem Switch-Port aus.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"site_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"device_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"port_idx": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"site_id",
|
||||
"device_id",
|
||||
"port_idx"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accepted": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"accepted"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "authorize_guest",
|
||||
"description": "Autorisiert einen UniFi-Gastclient optional zeitlich begrenzt.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"site_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"client_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"time_limit_minutes": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"site_id",
|
||||
"client_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "unauthorize_guest",
|
||||
"description": "Entzieht einem UniFi-Gastclient den Netzwerkzugang.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"site_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"client_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"site_id",
|
||||
"client_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import json, os, ssl, sys
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError, URLError
|
||||
PROTO='jarvis.skill.invoke.v1'
|
||||
def truthy(v): return str(v or '').strip().lower() in ('1','true','yes','on')
|
||||
def fail(c,m): return {'protocol':PROTO,'success':False,'error':{'code':c,'message':m}}
|
||||
def ok(d,m='OK',mut=False): return {'protocol':PROTO,'success':True,'data':d,'message':m,'mutated':mut}
|
||||
def cfg():
|
||||
b=os.getenv('UNIFI_PROTECT_URL','').strip().rstrip('/'); k=os.getenv('UNIFI_API_KEY','').strip()
|
||||
if not b or not k: raise RuntimeError('UNIFI_PROTECT_URL und UNIFI_API_KEY müssen gesetzt sein')
|
||||
if not b.startswith(('http://','https://')): b='https://'+b
|
||||
return b,k,truthy(os.getenv('UNIFI_VERIFY_TLS','false'))
|
||||
def request(method,path,body=None):
|
||||
b,k,verify=cfg(); data=None; h={'Accept':'application/json','X-API-Key':k}
|
||||
if body is not None: data=json.dumps(body).encode(); h['Content-Type']='application/json'
|
||||
ctx=ssl._create_unverified_context() if b.startswith('https://') and not verify else None
|
||||
try:
|
||||
with urlopen(Request(b+path,data=data,headers=h,method=method),timeout=8,context=ctx) as r:
|
||||
raw=r.read(); return json.loads(raw.decode()) if raw else {}
|
||||
except HTTPError as e: raise RuntimeError(f'UniFi Protect HTTP {e.code}: {e.read().decode(errors="replace")[:1000]}')
|
||||
except URLError as e: raise RuntimeError(f'UniFi Protect nicht erreichbar: {e.reason}')
|
||||
def name(v):
|
||||
n=v.get('name','') if isinstance(v,dict) else ''
|
||||
if isinstance(n,dict): return n.get('name') or n.get('value') or str(n)
|
||||
return n or ''
|
||||
def list_norm(path,kind):
|
||||
raw=request('GET',path); vals=raw if isinstance(raw,list) else raw.get('data',[]) if isinstance(raw,dict) else []
|
||||
out=[]
|
||||
for v in vals:
|
||||
base={'id':v.get('id',''),'name':name(v),'model':v.get('modelKey',''),'state':v.get('state',''),'mac':v.get('mac','')}
|
||||
if kind=='camera':
|
||||
f=v.get('featureFlags') or {}; base.update({'video_mode':v.get('videoMode',''),'hdr':v.get('hdrType',''),'has_mic':f.get('hasMic'),'has_speaker':f.get('hasSpeaker'),'smart_detect_types':f.get('smartDetectTypes') or []})
|
||||
out.append(base)
|
||||
return out
|
||||
def main(inv):
|
||||
a=inv.get('action'); x=inv.get('input') or {}
|
||||
if a=='info': return ok(request('GET','/v1/meta/info'),'Protect API erreichbar.')
|
||||
if a in ('list_cameras','list_sensors','list_lights'):
|
||||
typ={'list_cameras':('cameras','camera'),'list_sensors':('sensors','sensor'),'list_lights':('lights','light')}[a]
|
||||
vals=list_norm('/v1/'+typ[0],typ[1]); return ok({typ[0]:vals,'count':len(vals)},'Protect-Geräte geladen.')
|
||||
if a=='get_camera':
|
||||
raw=request('GET',f"/v1/cameras/{x['camera_id']}"); return ok(raw if isinstance(raw,dict) else {'camera':raw},'Kamera geladen.')
|
||||
if a=='ptz_goto': request('POST',f"/v1/cameras/{x['camera_id']}/ptz/goto/{int(x['slot'])}"); return ok({'accepted':True},'PTZ-Preset angefahren.',True)
|
||||
if a=='ptz_patrol_start': request('POST',f"/v1/cameras/{x['camera_id']}/ptz/patrol/start/{int(x['slot'])}"); return ok({'accepted':True},'PTZ-Patrouille gestartet.',True)
|
||||
if a=='ptz_patrol_stop': request('POST',f"/v1/cameras/{x['camera_id']}/ptz/patrol/stop"); return ok({'accepted':True},'PTZ-Patrouille gestoppt.',True)
|
||||
if a=='trigger_alarm_webhook': request('POST',f"/v1/alarm-manager/webhook/{x['trigger_id']}"); return ok({'accepted':True},'Protect-Alarm-Webhook ausgelöst.',True)
|
||||
return fail('UNKNOWN_ACTION',f'Unbekannte Aktion: {a}')
|
||||
try: res=main(json.load(sys.stdin))
|
||||
except Exception as e: res=fail('UNIFI_PROTECT_ERROR',str(e))
|
||||
json.dump(res,sys.stdout,ensure_ascii=False)
|
||||
@@ -0,0 +1,266 @@
|
||||
{
|
||||
"protocol": "jarvis.skill.v1",
|
||||
"id": "unifi.protect",
|
||||
"name": "UniFi Protect",
|
||||
"version": "1.0.0",
|
||||
"description": "Lokale UniFi-Protect-Integration über die offizielle Integration API.",
|
||||
"runtime": {
|
||||
"type": "process",
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"main.py"
|
||||
],
|
||||
"timeout_ms": 10000,
|
||||
"env_from": [
|
||||
"UNIFI_PROTECT_URL",
|
||||
"UNIFI_API_KEY",
|
||||
"UNIFI_VERIFY_TLS"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"network": true,
|
||||
"system_exec": true
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"name": "info",
|
||||
"description": "Prüft die Protect-API und liefert Versionsinformationen.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_cameras",
|
||||
"description": "Listet Protect-Kameras mit Status und Fähigkeiten.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cameras": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"cameras",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_camera",
|
||||
"description": "Liest Details einer Protect-Kamera.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"camera_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_sensors",
|
||||
"description": "Listet Protect-Sensoren.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sensors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sensors",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_lights",
|
||||
"description": "Listet Protect-Lights/Floodlights.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"lights": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"count": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"lights",
|
||||
"count"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ptz_goto",
|
||||
"description": "Fährt eine PTZ-Kamera auf ein vorhandenes Preset.",
|
||||
"mutates": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"slot": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"camera_id",
|
||||
"slot"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accepted": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"accepted"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ptz_patrol_start",
|
||||
"description": "Startet eine konfigurierte PTZ-Patrouille.",
|
||||
"mutates": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"slot": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"camera_id",
|
||||
"slot"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accepted": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"accepted"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ptz_patrol_stop",
|
||||
"description": "Stoppt die aktive PTZ-Patrouille.",
|
||||
"mutates": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"camera_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accepted": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"accepted"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "trigger_alarm_webhook",
|
||||
"description": "Triggert einen in Protect konfigurierten Alarm-Manager-Webhook.",
|
||||
"mutates": true,
|
||||
"requires_confirmation": true,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"trigger_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"trigger_id"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accepted": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"accepted"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user