feat(mx): add Nextcloud Talk opencode bot pointing at halo.hoyer.tail:8000
Mirrors the existing nextcloud-claude-bot setup but invokes `opencode run` against the local `halo-8000` provider/model. The bot listens on 127.0.0.1:8086, is exposed via the `/_opencode-bot/` location on nc.hoyer.xyz, and uses `@Halo` as its mention trigger in group chats. The opencode config (config/opencode/config.json) is installed into the service's $HOME/.config/opencode/ on each start, so the bot picks up the same provider definition the user uses interactively. The model map keys are renamed to `halo-8000` / `halo-8001` so the canonical `provider/model` reference works without an alias indirection.
This commit is contained in:
parent
dadfb07914
commit
d8e8293c0e
6 changed files with 576 additions and 2 deletions
35
.secrets/hetzner/nextcloud-opencode-bot.yaml
Normal file
35
.secrets/hetzner/nextcloud-opencode-bot.yaml
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
nextcloud-opencode-bot:
|
||||||
|
secret: ENC[AES256_GCM,data:TYSUYHzZGvCtJ3aCBS4h73LQHToqDcYV8CNdVqF8NmjdUkxO/6RsXUE3lMR7nE1T8YeJA2F9E3ABLJ71O8WSlg==,iv:Ho0g7HGShHOeso0B+ojwxM8A+P0epFvos/u+fJPZ+zY=,tag:Nk0D/Yt2/tPxCIownuEQnw==,type:str]
|
||||||
|
sops:
|
||||||
|
age:
|
||||||
|
- recipient: age1qur4kh3gay9ryk3jh2snvjp6x9eq94zdrmgkrfcv4fzsu7l6lumq4tr3uy
|
||||||
|
enc: |
|
||||||
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjZ1FZUFFicjdKOFRGaTNY
|
||||||
|
V0NDa2ZIeXJ4LzY3UU5pRnZlY3lQdFBRcVRrCjNNRFh2aE9vWktmWlFSTjVSRG42
|
||||||
|
eFcrTzh3SXhFTTNBbGZBOXg5UkZsRHMKLS0tIDFER21GVWw0L29obU5OYk1GR05C
|
||||||
|
Y3I0OFMxb2xPRjQrNVYvcGNNUmE5V28KkqgFKnwU94rFq9hQMKCoY/xG2M7tlhbj
|
||||||
|
pjtmQzh36oJ7w7ZOFC/6lShLMD2D8yEPDeqA7idHxVZnAYMf+hbi8g==
|
||||||
|
-----END AGE ENCRYPTED FILE-----
|
||||||
|
- recipient: age1dwcz3fmp29ju4svy0t0wz4ylhpwlqa8xpw4l7t4gmgqr0ev37qrsfn840l
|
||||||
|
enc: |
|
||||||
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBmS25iUFVpZTdYcDJRT2NQ
|
||||||
|
cFVSWFFaS1V4M2I1UVFLTGdjSFBGdStpc0ZVCmlXZ3FtKzMyV1VJQWZFNzhDR291
|
||||||
|
dSt5MUJEZ053TXhQUGNSUVFSNllJOUUKLS0tIGF6TDlPSHIrMkxpaW5LQ2ZOcHlj
|
||||||
|
c3I4aXFWUU05REN0SGRlY0UvejJKQkkKM2kiJ/dhDI658xwDFJFXWFdGeSl8bAgU
|
||||||
|
MXH8CyZ8f7ZfHQQAzJAtYgXxCLq+CCA/42yiFbFOCpGYJQBeOJgZpQ==
|
||||||
|
-----END AGE ENCRYPTED FILE-----
|
||||||
|
- recipient: age1cpm9xhgue7sjvq7zyeeaxwr96c93sfzxxxj76sxsq7s7kgnygvcq5jxren
|
||||||
|
enc: |
|
||||||
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAxSTJDS2lSUUlTRzJPTWpa
|
||||||
|
R1IxSGdNZnpqZXpRcE13THdERHN0RUVZclNvCm1WZEx4VFk5dFBhNERINjNZNEds
|
||||||
|
OVFHS2JXemUvZEJERkg0SGs0elBDZWcKLS0tIGpKRGF2Mnc5U00zT3JZWnhJZjE0
|
||||||
|
bFRwajlkaEhFcjJNaENuOFArS2EwVEkKt4t8Zoa20JJC4IHMNsVK7yvst2dJ9dQl
|
||||||
|
PMJ/ZUgGcE1fkH1FjfQP/e3LBnxovo+ep3NXFnP1zwje0c+tsXMX6g==
|
||||||
|
-----END AGE ENCRYPTED FILE-----
|
||||||
|
lastmodified: "2026-05-13T13:07:22Z"
|
||||||
|
mac: ENC[AES256_GCM,data:rnhSDTFAUTty4cMPw8hOrElcVm++kazQaeeVkApovwFwYppMlhanEV5kKRvPEqb53boNoP77K/KlhKVtD6gJprAvCSZhQ7N08AvIBrl/3fqOifSXh/iz/I5HEdwxFSRoHirlb1ZWcdvZRv/owOBtfZZ9S+ul5o89Og7g2Cf+hVg=,iv:DvBtkfYAxk4P1HvHSZANFdJTeDp6W22YW35vvJB5a/A=,tag:E7luVwe0uEXSmJl+GWGGmA==,type:str]
|
||||||
|
unencrypted_suffix: _unencrypted
|
||||||
|
version: 3.12.1
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
"baseURL": "http://halo.hoyer.tail:8000/v1"
|
"baseURL": "http://halo.hoyer.tail:8000/v1"
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"unsloth/Qwen3.6-27B-GGUF:UD-Q8_K_XL": { "name" : "halo8000" }
|
"halo-8000": { "name" : "halo-8000" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"halo-8001": {
|
"halo-8001": {
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
"baseURL": "http://halo.hoyer.tail:8001/v1"
|
"baseURL": "http://halo.hoyer.tail:8001/v1"
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"placeholder": { "name" : "halo8001" }
|
"halo-8001": { "name" : "halo-8001" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
./network.nix
|
./network.nix
|
||||||
./nextcloud.nix
|
./nextcloud.nix
|
||||||
./nextcloud-claude-bot
|
./nextcloud-claude-bot
|
||||||
|
./nextcloud-opencode-bot
|
||||||
./nginx.nix
|
./nginx.nix
|
||||||
./ntfy.nix
|
./ntfy.nix
|
||||||
./postgresql.nix
|
./postgresql.nix
|
||||||
|
|
|
||||||
325
systems/x86_64-linux/mx/nextcloud-opencode-bot/bot.py
Normal file
325
systems/x86_64-linux/mx/nextcloud-opencode-bot/bot.py
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Nextcloud Talk OpenCode Bot
|
||||||
|
|
||||||
|
Receives webhooks from Nextcloud Talk and responds using opencode CLI
|
||||||
|
against a local model exposed via the `halo-8000` provider.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, Request, HTTPException, Header
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
NEXTCLOUD_URL = os.environ.get("NEXTCLOUD_URL", "").rstrip("/")
|
||||||
|
OPENCODE_PATH = os.environ.get("OPENCODE_PATH", "opencode")
|
||||||
|
OPENCODE_MODEL = os.environ.get("OPENCODE_MODEL", "halo-8000/halo-8000")
|
||||||
|
ALLOWED_USERS = [u.strip() for u in os.environ.get("ALLOWED_USERS", "").split(",") if u.strip()]
|
||||||
|
TIMEOUT = int(os.environ.get("TIMEOUT", "120"))
|
||||||
|
SYSTEM_PROMPT = os.environ.get("SYSTEM_PROMPT", "")
|
||||||
|
BOT_NAME = os.environ.get("BOT_NAME", "Halo")
|
||||||
|
|
||||||
|
|
||||||
|
def get_bot_secret() -> str:
|
||||||
|
cred_path = os.environ.get("CREDENTIALS_DIRECTORY", "")
|
||||||
|
if cred_path:
|
||||||
|
secret_file = os.path.join(cred_path, "bot-secret")
|
||||||
|
if os.path.exists(secret_file):
|
||||||
|
with open(secret_file) as f:
|
||||||
|
return f.read().strip()
|
||||||
|
return os.environ.get("BOT_SECRET", "")
|
||||||
|
|
||||||
|
|
||||||
|
BOT_SECRET = get_bot_secret()
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s"
|
||||||
|
)
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = FastAPI(title="Nextcloud OpenCode Bot")
|
||||||
|
|
||||||
|
conversations: dict[str, list[tuple[str, str]]] = {}
|
||||||
|
MAX_HISTORY = int(os.environ.get("CONTEXT_MESSAGES", "6"))
|
||||||
|
|
||||||
|
|
||||||
|
def generate_bot_auth_headers(body: str = "") -> dict:
|
||||||
|
random = secrets.token_hex(32)
|
||||||
|
digest = hmac.new(
|
||||||
|
BOT_SECRET.encode(),
|
||||||
|
(random + body).encode(),
|
||||||
|
hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
return {
|
||||||
|
"X-Nextcloud-Talk-Bot-Random": random,
|
||||||
|
"X-Nextcloud-Talk-Bot-Signature": digest,
|
||||||
|
"OCS-APIRequest": "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def verify_signature(body: bytes, signature: str, random: Optional[str] = None) -> bool:
|
||||||
|
if not BOT_SECRET:
|
||||||
|
log.warning("No bot secret configured, skipping signature verification")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if signature.startswith("sha256="):
|
||||||
|
signature = signature[7:]
|
||||||
|
|
||||||
|
expected1 = hmac.new(BOT_SECRET.encode(), body, hashlib.sha256).hexdigest()
|
||||||
|
if random:
|
||||||
|
expected2 = hmac.new(BOT_SECRET.encode(), (random.encode() + body), hashlib.sha256).hexdigest()
|
||||||
|
else:
|
||||||
|
expected2 = None
|
||||||
|
|
||||||
|
if hmac.compare_digest(expected1, signature):
|
||||||
|
return True
|
||||||
|
if expected2 and hmac.compare_digest(expected2, signature):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
BOT_SYSTEM_PROMPT = """\
|
||||||
|
Du bist ein KI-Assistent im Nextcloud Talk Chat.
|
||||||
|
Deine Antworten werden direkt in den Chatraum gepostet.
|
||||||
|
Halte deine Antworten kurz und prägnant, da es ein Chat ist.
|
||||||
|
Nutze Markdown für Formatierung wenn sinnvoll.
|
||||||
|
|
||||||
|
Du erhältst:
|
||||||
|
- <chat_history>: Die letzten Nachrichten im Chatraum (User und deine Antworten)
|
||||||
|
- <current_message>: Die aktuelle Nachricht, auf die du antworten sollst"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_system_prompt() -> str:
|
||||||
|
if SYSTEM_PROMPT:
|
||||||
|
return f"{BOT_SYSTEM_PROMPT}\n\n{SYSTEM_PROMPT.strip()}"
|
||||||
|
return BOT_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
|
||||||
|
def build_prompt(conversation_token: str, current_message: str, current_user: str) -> str:
|
||||||
|
"""Build the full prompt. opencode run has no system-prompt flag, so we
|
||||||
|
inline the system instructions at the top."""
|
||||||
|
parts = [
|
||||||
|
"<system_instructions>",
|
||||||
|
build_system_prompt(),
|
||||||
|
"</system_instructions>",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
history = conversations.get(conversation_token, [])
|
||||||
|
if history:
|
||||||
|
parts.append("<chat_history>")
|
||||||
|
for role, msg in history[-MAX_HISTORY:]:
|
||||||
|
parts.append(f"{role}: {msg}")
|
||||||
|
parts.append("</chat_history>")
|
||||||
|
parts.append("")
|
||||||
|
|
||||||
|
parts.append(f"<current_message user=\"{current_user}\">")
|
||||||
|
parts.append(current_message)
|
||||||
|
parts.append("</current_message>")
|
||||||
|
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
async def call_opencode(prompt: str) -> str:
|
||||||
|
"""Call opencode CLI and return response."""
|
||||||
|
cmd = [OPENCODE_PATH, "run", "-m", OPENCODE_MODEL, prompt]
|
||||||
|
|
||||||
|
log.info(f"Calling opencode: {OPENCODE_PATH} run -m {OPENCODE_MODEL} ...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout, stderr = await asyncio.wait_for(
|
||||||
|
proc.communicate(),
|
||||||
|
timeout=TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
log.error(f"opencode CLI error: {stderr.decode()}")
|
||||||
|
return f"❌ Fehler beim Aufruf von opencode: {stderr.decode()[:200]}"
|
||||||
|
|
||||||
|
return stdout.decode().strip()
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
log.error(f"opencode CLI timeout after {TIMEOUT}s")
|
||||||
|
return f"⏱️ Timeout: opencode hat nicht innerhalb von {TIMEOUT}s geantwortet."
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Error calling opencode")
|
||||||
|
return f"❌ Fehler: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def send_reply(conversation_token: str, message: str, reply_to: int = None):
|
||||||
|
if not NEXTCLOUD_URL:
|
||||||
|
log.error("NEXTCLOUD_URL not configured")
|
||||||
|
return
|
||||||
|
|
||||||
|
url = f"{NEXTCLOUD_URL}/ocs/v2.php/apps/spreed/api/v1/bot/{conversation_token}/message"
|
||||||
|
|
||||||
|
headers = generate_bot_auth_headers(message)
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"message": message,
|
||||||
|
"referenceId": hashlib.sha256(
|
||||||
|
f"{conversation_token}-{datetime.now().isoformat()}".encode()
|
||||||
|
).hexdigest()[:32],
|
||||||
|
}
|
||||||
|
|
||||||
|
if reply_to:
|
||||||
|
payload["replyTo"] = reply_to
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
resp = await client.post(url, json=payload, headers=headers)
|
||||||
|
if resp.status_code not in (200, 201):
|
||||||
|
log.error(f"Failed to send reply: {resp.status_code} {resp.text}")
|
||||||
|
else:
|
||||||
|
log.info(f"Reply sent to conversation {conversation_token}")
|
||||||
|
except Exception:
|
||||||
|
log.exception("Error sending reply to Nextcloud")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/webhook")
|
||||||
|
async def handle_webhook(
|
||||||
|
request: Request,
|
||||||
|
x_nextcloud_talk_signature: Optional[str] = Header(None, alias="X-Nextcloud-Talk-Signature"),
|
||||||
|
x_nextcloud_talk_random: Optional[str] = Header(None, alias="X-Nextcloud-Talk-Random"),
|
||||||
|
):
|
||||||
|
body = await request.body()
|
||||||
|
|
||||||
|
if x_nextcloud_talk_signature and not verify_signature(body, x_nextcloud_talk_signature, x_nextcloud_talk_random):
|
||||||
|
log.warning("Invalid webhook signature")
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid signature")
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid JSON")
|
||||||
|
|
||||||
|
log.info(f"Received webhook: {json.dumps(data, indent=2)[:500]}")
|
||||||
|
|
||||||
|
actor = data.get("actor", {})
|
||||||
|
actor_type = actor.get("type", "")
|
||||||
|
actor_id_full = actor.get("id", "")
|
||||||
|
|
||||||
|
if "/" in actor_id_full:
|
||||||
|
actor_id = actor_id_full.split("/", 1)[1]
|
||||||
|
else:
|
||||||
|
actor_id = actor_id_full
|
||||||
|
|
||||||
|
obj = data.get("object", {})
|
||||||
|
message_id = obj.get("id")
|
||||||
|
content_str = obj.get("content", "{}")
|
||||||
|
try:
|
||||||
|
content = json.loads(content_str)
|
||||||
|
message_text = content.get("message", "")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
message_text = content_str
|
||||||
|
|
||||||
|
target = data.get("target", {})
|
||||||
|
conversation_token = target.get("id", "")
|
||||||
|
|
||||||
|
if actor_type not in ("users", "Person"):
|
||||||
|
log.info(f"Ignoring non-user actor: {actor_type}")
|
||||||
|
return JSONResponse({"status": "ignored", "reason": "not a user message"})
|
||||||
|
|
||||||
|
is_direct_message = False
|
||||||
|
|
||||||
|
bot_mentioned = False
|
||||||
|
clean_message = message_text
|
||||||
|
|
||||||
|
escaped = re.escape(BOT_NAME)
|
||||||
|
mention_patterns = [
|
||||||
|
rf'@"?{escaped}"?\s*',
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in mention_patterns:
|
||||||
|
if re.search(pattern, message_text, re.IGNORECASE):
|
||||||
|
bot_mentioned = True
|
||||||
|
clean_message = re.sub(pattern, '', message_text, flags=re.IGNORECASE).strip()
|
||||||
|
break
|
||||||
|
|
||||||
|
if not is_direct_message and not bot_mentioned:
|
||||||
|
log.info("Ignoring message in group chat without mention")
|
||||||
|
return JSONResponse({"status": "ignored", "reason": "not mentioned in group chat"})
|
||||||
|
|
||||||
|
if bot_mentioned:
|
||||||
|
message_text = clean_message
|
||||||
|
|
||||||
|
if ALLOWED_USERS and actor_id not in ALLOWED_USERS:
|
||||||
|
log.warning(f"User {actor_id} not in allowed list")
|
||||||
|
await send_reply(
|
||||||
|
conversation_token,
|
||||||
|
"🚫 Du bist nicht berechtigt, diesen Bot zu nutzen.",
|
||||||
|
reply_to=message_id
|
||||||
|
)
|
||||||
|
return JSONResponse({"status": "rejected", "reason": "user not allowed"})
|
||||||
|
|
||||||
|
if not message_text.strip():
|
||||||
|
return JSONResponse({"status": "ignored", "reason": "empty message"})
|
||||||
|
|
||||||
|
log.info(f"Processing message from {actor_id}: {message_text[:100]}")
|
||||||
|
|
||||||
|
if message_text.strip().lower() in ("hilfe", "help", "?"):
|
||||||
|
help_text = f"""🤖 **{BOT_NAME} Bot Hilfe**
|
||||||
|
|
||||||
|
Schreib mir einfach eine Nachricht und ich antworte dir.
|
||||||
|
|
||||||
|
**Nutzung:**
|
||||||
|
• In Gruppenchats: @{BOT_NAME} gefolgt von deiner Frage
|
||||||
|
|
||||||
|
**Befehle:**
|
||||||
|
• `hilfe` oder `?` – Diese Hilfe anzeigen
|
||||||
|
|
||||||
|
Modell: `{OPENCODE_MODEL}`
|
||||||
|
Der Bot merkt sich die letzten Nachrichten pro Raum (bis zum Neustart)."""
|
||||||
|
await send_reply(conversation_token, help_text, reply_to=message_id)
|
||||||
|
return JSONResponse({"status": "ok", "action": "help"})
|
||||||
|
|
||||||
|
prompt = build_prompt(conversation_token, message_text, actor_id)
|
||||||
|
response = await call_opencode(prompt)
|
||||||
|
|
||||||
|
if conversation_token not in conversations:
|
||||||
|
conversations[conversation_token] = []
|
||||||
|
conversations[conversation_token].append((f"User ({actor_id})", message_text))
|
||||||
|
conversations[conversation_token].append(("Assistant", response))
|
||||||
|
|
||||||
|
if len(conversations[conversation_token]) > MAX_HISTORY * 2:
|
||||||
|
conversations[conversation_token] = conversations[conversation_token][-MAX_HISTORY * 2:]
|
||||||
|
|
||||||
|
await send_reply(conversation_token, response, reply_to=message_id)
|
||||||
|
|
||||||
|
return JSONResponse({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"nextcloud_url": NEXTCLOUD_URL,
|
||||||
|
"opencode_path": OPENCODE_PATH,
|
||||||
|
"opencode_model": OPENCODE_MODEL,
|
||||||
|
"bot_name": BOT_NAME,
|
||||||
|
"allowed_users": ALLOWED_USERS if ALLOWED_USERS else "all",
|
||||||
|
"max_history": MAX_HISTORY,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="127.0.0.1", port=8086)
|
||||||
35
systems/x86_64-linux/mx/nextcloud-opencode-bot/default.nix
Normal file
35
systems/x86_64-linux/mx/nextcloud-opencode-bot/default.nix
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
{ config, ... }:
|
||||||
|
{
|
||||||
|
imports = [ ./module.nix ];
|
||||||
|
|
||||||
|
services.nextcloud-opencode-bot = {
|
||||||
|
enable = true;
|
||||||
|
nextcloudUrl = "https://nc.hoyer.xyz";
|
||||||
|
botSecretFile = config.sops.secrets."nextcloud-opencode-bot/secret".path;
|
||||||
|
opencodeConfig = ../../../../config/opencode/config.json;
|
||||||
|
model = "halo-8000/halo-8000";
|
||||||
|
botName = "Halo";
|
||||||
|
allowedUsers = [ ];
|
||||||
|
};
|
||||||
|
|
||||||
|
sops.secrets."nextcloud-opencode-bot/secret" = {
|
||||||
|
sopsFile = ../../../../.secrets/hetzner/nextcloud-opencode-bot.yaml;
|
||||||
|
restartUnits = [ "nextcloud-opencode-bot.service" ];
|
||||||
|
owner = "opencode-bot";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Nginx location for Nextcloud to send webhooks to the bot
|
||||||
|
services.nginx.virtualHosts."nc.hoyer.xyz".locations."/_opencode-bot/" = {
|
||||||
|
proxyPass = "http://127.0.0.1:8086/";
|
||||||
|
extraConfig = ''
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Only allow from localhost (Nextcloud on same server)
|
||||||
|
allow 127.0.0.1;
|
||||||
|
deny all;
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
178
systems/x86_64-linux/mx/nextcloud-opencode-bot/module.nix
Normal file
178
systems/x86_64-linux/mx/nextcloud-opencode-bot/module.nix
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.services.nextcloud-opencode-bot;
|
||||||
|
|
||||||
|
pythonEnv = pkgs.python3.withPackages (
|
||||||
|
ps: with ps; [
|
||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
httpx
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
botModule = pkgs.runCommand "nextcloud-opencode-bot-module" { } ''
|
||||||
|
mkdir -p $out
|
||||||
|
cp ${./bot.py} $out/nextcloud_opencode_bot.py
|
||||||
|
'';
|
||||||
|
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.nextcloud-opencode-bot = {
|
||||||
|
enable = mkEnableOption "Nextcloud Talk OpenCode Bot";
|
||||||
|
|
||||||
|
port = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 8086;
|
||||||
|
description = "Port for the webhook listener";
|
||||||
|
};
|
||||||
|
|
||||||
|
host = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "127.0.0.1";
|
||||||
|
description = "Host to bind to";
|
||||||
|
};
|
||||||
|
|
||||||
|
nextcloudUrl = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
example = "https://cloud.example.com";
|
||||||
|
description = "Base URL of your Nextcloud instance";
|
||||||
|
};
|
||||||
|
|
||||||
|
botSecretFile = mkOption {
|
||||||
|
type = types.path;
|
||||||
|
description = "Path to file containing the bot secret (shared with Nextcloud)";
|
||||||
|
};
|
||||||
|
|
||||||
|
opencodePath = mkOption {
|
||||||
|
type = types.path;
|
||||||
|
default = "${pkgs.opencode}/bin/opencode";
|
||||||
|
description = "Path to opencode CLI binary";
|
||||||
|
};
|
||||||
|
|
||||||
|
opencodeConfig = mkOption {
|
||||||
|
type = types.path;
|
||||||
|
description = "Path to the opencode config.json file (placed at $HOME/.config/opencode/config.json on service start)";
|
||||||
|
};
|
||||||
|
|
||||||
|
model = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "halo-8000/halo-8000";
|
||||||
|
description = "Model identifier passed to `opencode run -m`";
|
||||||
|
};
|
||||||
|
|
||||||
|
botName = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "Halo";
|
||||||
|
description = "Bot display name (used to detect @mentions in group chats)";
|
||||||
|
};
|
||||||
|
|
||||||
|
allowedUsers = mkOption {
|
||||||
|
type = types.listOf types.str;
|
||||||
|
default = [ ];
|
||||||
|
description = "Nextcloud usernames allowed to talk to the bot (empty = all)";
|
||||||
|
};
|
||||||
|
|
||||||
|
contextMessages = mkOption {
|
||||||
|
type = types.int;
|
||||||
|
default = 6;
|
||||||
|
description = "Number of recent messages to keep as context";
|
||||||
|
};
|
||||||
|
|
||||||
|
timeout = mkOption {
|
||||||
|
type = types.int;
|
||||||
|
default = 120;
|
||||||
|
description = "Timeout in seconds for opencode CLI";
|
||||||
|
};
|
||||||
|
|
||||||
|
systemPrompt = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Optional additional system prompt appended to the built-in one";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = mkIf cfg.enable {
|
||||||
|
systemd.services.nextcloud-opencode-bot = {
|
||||||
|
description = "Nextcloud Talk OpenCode Bot";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "network.target" ];
|
||||||
|
|
||||||
|
path = with pkgs; [
|
||||||
|
bash
|
||||||
|
coreutils
|
||||||
|
git
|
||||||
|
curl
|
||||||
|
jq
|
||||||
|
];
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
HOME = "/var/lib/nextcloud-opencode-bot";
|
||||||
|
BOT_HOST = cfg.host;
|
||||||
|
BOT_PORT = toString cfg.port;
|
||||||
|
NEXTCLOUD_URL = cfg.nextcloudUrl;
|
||||||
|
OPENCODE_PATH = cfg.opencodePath;
|
||||||
|
OPENCODE_MODEL = cfg.model;
|
||||||
|
BOT_NAME = cfg.botName;
|
||||||
|
ALLOWED_USERS = concatStringsSep "," cfg.allowedUsers;
|
||||||
|
CONTEXT_MESSAGES = toString cfg.contextMessages;
|
||||||
|
TIMEOUT = toString cfg.timeout;
|
||||||
|
SYSTEM_PROMPT = cfg.systemPrompt or "";
|
||||||
|
PYTHONPATH = botModule;
|
||||||
|
};
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
|
||||||
|
# Materialize the opencode config at the path opencode looks for by
|
||||||
|
# default ($HOME/.config/opencode/config.json). We copy rather than
|
||||||
|
# symlink so opencode's config loader sees a regular file.
|
||||||
|
ExecStartPre = pkgs.writeShellScript "install-opencode-config" ''
|
||||||
|
set -eu
|
||||||
|
install -d -m 0700 "$HOME/.config/opencode"
|
||||||
|
install -m 0600 ${cfg.opencodeConfig} "$HOME/.config/opencode/config.json"
|
||||||
|
'';
|
||||||
|
|
||||||
|
ExecStart = "${pythonEnv}/bin/uvicorn nextcloud_opencode_bot:app --host ${cfg.host} --port ${toString cfg.port}";
|
||||||
|
Restart = "always";
|
||||||
|
RestartSec = 5;
|
||||||
|
|
||||||
|
User = "opencode-bot";
|
||||||
|
Group = "opencode-bot";
|
||||||
|
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ProtectHome = "read-only";
|
||||||
|
PrivateTmp = true;
|
||||||
|
PrivateDevices = true;
|
||||||
|
ProtectKernelTunables = true;
|
||||||
|
ProtectKernelModules = true;
|
||||||
|
ProtectControlGroups = true;
|
||||||
|
RestrictNamespaces = true;
|
||||||
|
RestrictRealtime = true;
|
||||||
|
RestrictSUIDSGID = true;
|
||||||
|
MemoryDenyWriteExecute = false;
|
||||||
|
LockPersonality = true;
|
||||||
|
|
||||||
|
LoadCredential = "bot-secret:${cfg.botSecretFile}";
|
||||||
|
|
||||||
|
StateDirectory = "nextcloud-opencode-bot";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
users.users.opencode-bot = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = "opencode-bot";
|
||||||
|
home = "/var/lib/nextcloud-opencode-bot";
|
||||||
|
};
|
||||||
|
|
||||||
|
users.groups.opencode-bot = { };
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue