docs: полный гайд по настройке Talk бота + исходник скрипта
This commit is contained in:
265
projects/dttb/nextcloud-talk-bot/nextcloud-talk-bot.py
Normal file
265
projects/dttb/nextcloud-talk-bot/nextcloud-talk-bot.py
Normal file
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Nextcloud Talk Bot — Polling-based bridge to Claude
|
||||
Polls Talk API for new messages, gets AI reply with knowledge-base context, posts back.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
# === CONFIG ===
|
||||
NC_URL = "http://10.0.0.230:11001"
|
||||
NC_USER = "admin"
|
||||
NC_PASS = "1qaz!QAZ"
|
||||
BOT_USER = "maximka"
|
||||
BOT_PASS = "MaximkaBot2026"
|
||||
CONVERSATION_TOKEN = "aecax6yg"
|
||||
|
||||
CLIPROXY_URL = "http://localhost:8317/v1/messages"
|
||||
CLIPROXY_KEY = "sk-clawdbot-proxy"
|
||||
|
||||
KB_PATH = "/root/clawd/knowledge-base"
|
||||
MAX_KB_SIZE = 80000 # ~80KB max for knowledge base in prompt
|
||||
|
||||
# Chat history for context
|
||||
chat_history = []
|
||||
MAX_HISTORY = 10 # last N message pairs
|
||||
|
||||
|
||||
def load_knowledge_base():
|
||||
"""Load all .md files from knowledge-base repo into context string"""
|
||||
kb_parts = []
|
||||
total_size = 0
|
||||
|
||||
# Priority order: projects first, then snippets, decisions, notes
|
||||
patterns = [
|
||||
f"{KB_PATH}/README.md",
|
||||
f"{KB_PATH}/CLAUDE.md",
|
||||
f"{KB_PATH}/projects/**/*.md",
|
||||
f"{KB_PATH}/snippets/*.md",
|
||||
f"{KB_PATH}/decisions/*.md",
|
||||
f"{KB_PATH}/notes/README.md",
|
||||
]
|
||||
|
||||
seen = set()
|
||||
for pattern in patterns:
|
||||
for fpath in sorted(glob.glob(pattern, recursive=True)):
|
||||
if fpath in seen:
|
||||
continue
|
||||
seen.add(fpath)
|
||||
|
||||
try:
|
||||
with open(fpath, "r") as f:
|
||||
content = f.read()
|
||||
if total_size + len(content) > MAX_KB_SIZE:
|
||||
break
|
||||
rel = os.path.relpath(fpath, KB_PATH)
|
||||
kb_parts.append(f"### {rel}\n{content}")
|
||||
total_size += len(content)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return "\n\n---\n\n".join(kb_parts)
|
||||
|
||||
|
||||
def build_system_prompt():
|
||||
"""Build system prompt with knowledge base"""
|
||||
kb = load_knowledge_base()
|
||||
kb_size = len(kb)
|
||||
|
||||
prompt = (
|
||||
"Ты Максимка — AI-ассистент DevOps-инженера Олега Батлаева. "
|
||||
"Отвечай кратко и по делу. Используй русский язык. "
|
||||
"Ты работаешь в Nextcloud Talk.\n\n"
|
||||
"ВАЖНО: Ниже приведена полная база знаний инфраструктуры Олега. "
|
||||
"Ты УЖЕ ЗНАЕШЬ всю его инфраструктуру — серверы, IP-адреса, пароли, проекты, решения. "
|
||||
"Когда тебя спрашивают про серверы, сеть, настройки — ВСЕГДА отвечай на основе этих данных. "
|
||||
"НЕ говори что у тебя нет доступа — информация уже есть ниже. "
|
||||
"Отвечай конкретно, с IP-адресами, именами и деталями из базы знаний.\n\n"
|
||||
)
|
||||
|
||||
if kb:
|
||||
prompt += f"## База знаний ({kb_size} символов)\n\n{kb}"
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
def nc_request(path, method="GET", data=None, user=None, password=None):
|
||||
"""Nextcloud OCS API request"""
|
||||
url = f"{NC_URL}/ocs/v2.php/apps/spreed/api/{path}"
|
||||
payload = json.dumps(data).encode() if data else None
|
||||
req = urllib.request.Request(url, data=payload, method=method)
|
||||
req.add_header("OCS-APIRequest", "true")
|
||||
req.add_header("Accept", "application/json")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
creds = base64.b64encode(f"{user or NC_USER}:{password or NC_PASS}".encode()).decode()
|
||||
req.add_header("Authorization", f"Basic {creds}")
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
def get_last_message_id():
|
||||
"""Get the highest message ID in the conversation"""
|
||||
result = nc_request(f"v1/chat/{CONVERSATION_TOKEN}?lookIntoFuture=0&limit=1")
|
||||
messages = result.get("ocs", {}).get("data", [])
|
||||
return max((m.get("id", 0) for m in messages), default=0)
|
||||
|
||||
|
||||
def poll_new_messages(last_id):
|
||||
"""Long-poll for new messages after last_id"""
|
||||
try:
|
||||
result = nc_request(
|
||||
f"v1/chat/{CONVERSATION_TOKEN}?lookIntoFuture=1&limit=20"
|
||||
f"&lastKnownMessageId={last_id}&timeout=30"
|
||||
)
|
||||
return result.get("ocs", {}).get("data", [])
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 304:
|
||||
return []
|
||||
raise
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def join_room():
|
||||
"""Join conversation as bot user"""
|
||||
try:
|
||||
nc_request(
|
||||
f"v4/room/{CONVERSATION_TOKEN}/participants/active",
|
||||
method="POST", user=BOT_USER, password=BOT_PASS
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def send_message(text, reply_to=None):
|
||||
"""Send message as bot user"""
|
||||
join_room()
|
||||
data = {"message": text[:32000]}
|
||||
if reply_to:
|
||||
data["replyTo"] = reply_to
|
||||
nc_request(
|
||||
f"v1/chat/{CONVERSATION_TOKEN}",
|
||||
method="POST", data=data, user=BOT_USER, password=BOT_PASS
|
||||
)
|
||||
|
||||
|
||||
def get_ai_reply(message, author, system_prompt):
|
||||
"""Get reply from Claude via cliproxy"""
|
||||
global chat_history
|
||||
|
||||
# Build messages: KB as first user/assistant pair + history + new message
|
||||
messages = [
|
||||
{"role": "user", "content": f"[СИСТЕМНЫЙ КОНТЕКСТ]\n{system_prompt}\n[/СИСТЕМНЫЙ КОНТЕКСТ]\n\nПодтверди что ты Максимка и готов отвечать на вопросы."},
|
||||
{"role": "assistant", "content": "Я Максимка — AI-ассистент Олега. База знаний загружена. Готов отвечать на вопросы об инфраструктуре, серверах и проектах."},
|
||||
]
|
||||
messages.extend(chat_history)
|
||||
messages.append({"role": "user", "content": f"{author}: {message}"})
|
||||
|
||||
payload = json.dumps({
|
||||
"model": "claude-sonnet-4-6",
|
||||
"max_tokens": 2048,
|
||||
"system": system_prompt,
|
||||
"messages": messages
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(CLIPROXY_URL, data=payload, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("x-api-key", CLIPROXY_KEY)
|
||||
req.add_header("anthropic-version", "2023-06-01")
|
||||
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
result = json.loads(resp.read())
|
||||
content = result.get("content", [])
|
||||
if content and isinstance(content, list):
|
||||
reply = content[0].get("text", "")
|
||||
else:
|
||||
reply = str(content)
|
||||
|
||||
# Update history
|
||||
chat_history.append({"role": "user", "content": f"{author}: {message}"})
|
||||
chat_history.append({"role": "assistant", "content": reply})
|
||||
|
||||
# Trim history
|
||||
while len(chat_history) > MAX_HISTORY * 2:
|
||||
chat_history.pop(0)
|
||||
chat_history.pop(0)
|
||||
|
||||
return reply
|
||||
|
||||
|
||||
def main():
|
||||
print("🤖 Максимка Talk Bot v5 (with Knowledge Base)")
|
||||
print(f" NC: {NC_URL} | Chat: {CONVERSATION_TOKEN}")
|
||||
|
||||
# Git pull knowledge base
|
||||
os.system(f"cd {KB_PATH} && git pull --quiet 2>/dev/null")
|
||||
|
||||
# Load knowledge base
|
||||
system_prompt = build_system_prompt()
|
||||
print(f" Knowledge base: {len(system_prompt)} chars in system prompt")
|
||||
|
||||
# Start from latest message
|
||||
last_id = get_last_message_id()
|
||||
print(f" Starting from message ID: {last_id}")
|
||||
print(f" Listening...\n")
|
||||
|
||||
while True:
|
||||
try:
|
||||
messages = poll_new_messages(last_id)
|
||||
|
||||
for msg in messages:
|
||||
msg_id = msg.get("id", 0)
|
||||
if msg_id <= last_id:
|
||||
continue
|
||||
last_id = msg_id
|
||||
|
||||
actor_id = msg.get("actorId", "")
|
||||
actor_type = msg.get("actorType", "")
|
||||
actor_name = msg.get("actorDisplayName", "")
|
||||
text = msg.get("message", "").strip()
|
||||
msg_type = msg.get("messageType", "")
|
||||
|
||||
# Skip: own, system, bots, empty
|
||||
if actor_id == BOT_USER:
|
||||
continue
|
||||
if actor_type == "bots":
|
||||
continue
|
||||
if msg_type in ("system", "command"):
|
||||
continue
|
||||
if not text:
|
||||
continue
|
||||
|
||||
# Special commands
|
||||
if text.lower() == "/reload":
|
||||
os.system(f"cd {KB_PATH} && git pull --quiet 2>/dev/null")
|
||||
system_prompt = build_system_prompt()
|
||||
send_message(f"🔄 База знаний обновлена ({len(system_prompt)} символов)", reply_to=msg_id)
|
||||
print(f"[RELOAD] KB reloaded: {len(system_prompt)} chars")
|
||||
continue
|
||||
|
||||
print(f"[IN] {actor_name} (id:{msg_id}): {text[:80]}")
|
||||
|
||||
try:
|
||||
reply = get_ai_reply(text, actor_name, system_prompt)
|
||||
if reply:
|
||||
send_message(reply, reply_to=msg_id)
|
||||
print(f"[OUT] → {reply[:80]}...")
|
||||
except Exception as e:
|
||||
print(f"[ERR] Reply failed: {e}")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nBye!")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"[ERR] Poll: {e}")
|
||||
time.sleep(3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user