diff --git a/projects/dttb/nextcloud-talk-bot/README.md b/projects/dttb/nextcloud-talk-bot/README.md index ea40410..f968607 100644 --- a/projects/dttb/nextcloud-talk-bot/README.md +++ b/projects/dttb/nextcloud-talk-bot/README.md @@ -1,94 +1,246 @@ -# Nextcloud Talk Bot — Максимка +# Nextcloud Talk Bot — Максимка 🤖 ## Обзор -AI-бот для Nextcloud Talk, который отвечает на сообщения в чате используя Claude через cliproxy (Clawdbot). +AI-бот в Nextcloud Talk с доступом к базе знаний. Отвечает на вопросы об инфраструктуре, серверах, настройках и проектах. Использует Claude через cliproxy (Clawdbot). ## Архитектура ``` Пользователь → Nextcloud Talk (чат "Максимка") - ↓ (polling API) - Talk Bot (Python) ← порт 3401 не используется, polling + ↓ (long polling API каждые 30с) + Talk Bot (Python скрипт на LXC 129) ↓ cliproxy (localhost:8317) → Claude Sonnet + ↑ + База знаний из Gitea (knowledge-base repo) ↓ - Ответ → Talk API → Чат + Ответ → Talk API → Чат (от пользователя "maximka") ``` -## Компоненты - -### 1. Nextcloud Talk Bot (зарегистрированный) -- **Bot ID:** 2 -- **Name:** Максимка -- **Secret:** `maximka-talk-bot-secret-key-2026-very-long-40chars` -- **Webhook URL:** `http://10.0.0.206:3401/nextcloud-talk/webhook` -- **Features:** webhook, response, event -- **Статус:** Webhook не работает (Docker networking), используем polling - -### 2. Python Polling Bot -- **Скрипт:** `/root/clawd/scripts/nextcloud-talk-bot.py` -- **Метод:** Long polling через Talk API (`lookIntoFuture=1`) -- **AI:** Claude Sonnet через cliproxy на localhost:8317 -- **Лог:** `/tmp/talk-bot.log` - -### 3. Nextcloud пользователи -- **admin** (1qaz!QAZ) — для чтения сообщений (polling) -- **maximka** (MaximkaBot2026) — для отправки ответов - -## Чат -- **Имя:** Максимка -- **Token:** `aecax6yg` -- **Участники:** admin, maximka - -## Nextcloud -- **URL:** http://10.0.0.230:11001 -- **Тип:** Nextcloud AIO (Docker) -- **VM:** 10.0.0.230 (cloud@10.0.0.230) - -## Запуск +## Быстрый старт +### Запуск бота ```bash -# Запуск бота +# На LXC 129 (clawdbot, 10.0.0.206) nohup python3 -u /root/clawd/scripts/nextcloud-talk-bot.py > /tmp/talk-bot.log 2>&1 & +``` -# Проверка +### Проверка +```bash cat /tmp/talk-bot.log | tail -20 +pgrep -f nextcloud-talk-bot +``` -# Остановка +### Остановка +```bash pkill -f nextcloud-talk-bot ``` -## Управление через OCC (внутри контейнера) +### Обновить базу знаний (из чата Talk) +Написать `/reload` в чат — бот сделает git pull и перезагрузит KB. + +--- + +## Установка с нуля + +### Предварительные требования +- Nextcloud с приложением Talk (протестировано на Nextcloud AIO) +- Python 3.x на машине где будет бот +- Clawdbot с cliproxy (для Claude API) +- Git репозиторий с базой знаний + +### Шаг 1: Создать пользователя для бота в Nextcloud + +```bash +# На VM с Nextcloud (10.0.0.230) +# Через OCC в Docker контейнере +sudo docker exec -i -e OC_PASS='MaximkaBot2026' -u www-data \ + nextcloud-aio-nextcloud php occ user:add \ + --password-from-env \ + --display-name="Максимка 🤖" \ + maximka + +# Если пользователь уже есть — сбросить пароль: +sudo docker exec -i -e OC_PASS='MaximkaBot2026' -u www-data \ + nextcloud-aio-nextcloud php occ user:resetpassword --password-from-env maximka +``` + +### Шаг 2: Создать чат в Nextcloud Talk + +Через веб-интерфейс Nextcloud Talk: +1. Нажать "+" → Создать групповую беседу +2. Назвать "Максимка" (или любое имя) +3. Добавить участников: admin, maximka, и других пользователей + +Через API: +```bash +# Создать групповой чат +curl -s -u "admin:1qaz!QAZ" \ + -X POST "http://10.0.0.230:11001/ocs/v2.php/apps/spreed/api/v4/room" \ + -H "OCS-APIRequest: true" \ + -d "roomType=2&roomName=Максимка" + +# Добавить бота в чат +curl -s -u "admin:1qaz!QAZ" \ + -X POST "http://10.0.0.230:11001/ocs/v2.php/apps/spreed/api/v4/room/{TOKEN}/participants" \ + -H "OCS-APIRequest: true" \ + -d "newParticipant=maximka&source=users" +``` + +Где `{TOKEN}` — токен чата из предыдущего ответа (у нас: `aecax6yg`). + +### Шаг 3: Получить токен чата + +```bash +# Список всех чатов +curl -s -u "admin:1qaz!QAZ" \ + "http://10.0.0.230:11001/ocs/v2.php/apps/spreed/api/v4/room" \ + -H "OCS-APIRequest: true" \ + -H "Accept: application/json" | python3 -c " +import sys, json +data = json.load(sys.stdin) +for r in data['ocs']['data']: + print(f\"Token: {r['token']} | Name: {r.get('displayName','')} | Type: {r['type']}\") +" +``` + +### Шаг 4: Скопировать скрипт бота + +Скрипт: `/root/clawd/scripts/nextcloud-talk-bot.py` + +Отредактировать конфигурацию в начале файла: +```python +NC_URL = "http://10.0.0.230:11001" # URL Nextcloud +NC_USER = "admin" # Пользователь для чтения (polling) +NC_PASS = "1qaz!QAZ" # Пароль +BOT_USER = "maximka" # Пользователь для отправки ответов +BOT_PASS = "MaximkaBot2026" # Пароль бота +CONVERSATION_TOKEN = "aecax6yg" # Токен чата + +CLIPROXY_URL = "http://localhost:8317/v1/messages" # Claude API +CLIPROXY_KEY = "sk-clawdbot-proxy" # API ключ + +KB_PATH = "/root/clawd/knowledge-base" # Путь к базе знаний +MAX_KB_SIZE = 80000 # Макс размер KB в промпте +``` + +### Шаг 5: Запустить бота + +```bash +nohup python3 -u /root/clawd/scripts/nextcloud-talk-bot.py > /tmp/talk-bot.log 2>&1 & +``` + +--- + +## Конфигурация + +### Учётные данные +| Что | Логин | Пароль | Назначение | +|-----|-------|--------|-----------| +| NC Polling | admin | 1qaz!QAZ | Чтение сообщений | +| NC Bot | maximka | MaximkaBot2026 | Отправка ответов | +| cliproxy | - | sk-clawdbot-proxy | Claude API | + +### Nextcloud +- **URL:** http://10.0.0.230:11001 +- **Тип:** Nextcloud AIO (Docker) +- **VM:** 250 на Proxmox (10.0.0.230) +- **SSH:** cloud@10.0.0.230 + +### Чат +- **Имя:** Максимка +- **Token:** aecax6yg +- **Участники:** admin, maximka + +### Файлы +- **Скрипт:** `/root/clawd/scripts/nextcloud-talk-bot.py` +- **Лог:** `/tmp/talk-bot.log` +- **База знаний:** `/root/clawd/knowledge-base/` +- **Gitea repo:** http://10.0.0.189:3000/oleg/knowledge-base + +--- + +## Как работает + +### Polling +Бот использует Talk API с long polling (`lookIntoFuture=1&timeout=30`). Nextcloud держит HTTP соединение открытым до 30 секунд и отвечает сразу когда появляется новое сообщение. + +### Join Room +Перед каждой отправкой бот делает `POST .../room/{token}/participants/active` — это "join" в разговор. Без этого Nextcloud возвращает 401. + +### База знаний +При старте бот: +1. Делает `git pull` в `/root/clawd/knowledge-base` +2. Загружает все `.md` файлы (до 80KB) +3. Передаёт как первое user/assistant сообщение в контекст Claude + +**Важно:** KB передаётся через user message, а не system prompt — cliproxy не передаёт system field. + +### Фильтрация +Бот пропускает: +- Свои сообщения (`actorId == "maximka"`) +- Сообщения от ботов (`actorType == "bots"`) +- Системные (`messageType == "system"`) + +### Команды +- `/reload` — обновить базу знаний (git pull + перезагрузка) + +### История +Бот помнит последние 10 пар сообщений (user/assistant) в текущей сессии. При перезапуске история сбрасывается. + +--- + +## Проблемы и решения + +### Webhook не работает +Nextcloud Talk Bot API поддерживает webhooks, но в AIO (Docker) setup они не триггерятся надёжно. Решение: polling. + +### 401 при отправке +Причина: бот не "вошёл" в чат. Решение: `join_room()` перед каждой отправкой. + +### Бот зацикливается +Причина: бот отвечает на свои же сообщения. Решение: фильтр по `actorId`. + +### Бот не использует базу знаний +Причина: cliproxy не передаёт system prompt. Решение: KB как первое user/assistant сообщение. + +### Бот не запускается после перезагрузки +Нужен systemd сервис (TODO). + +--- + +## OCC команды (управление Talk ботами) ```bash # SSH на VM ssh cloud@10.0.0.230 -# Список ботов -echo '1qaz!QAZ' | sudo -S docker exec -u www-data nextcloud-aio-nextcloud php occ talk:bot:list aecax6yg +# Все команды через docker +EXEC="sudo docker exec -u www-data nextcloud-aio-nextcloud" -# Список чатов -curl -s -u "admin:1qaz!QAZ" "http://10.0.0.230:11001/ocs/v2.php/apps/spreed/api/v4/room" -H "OCS-APIRequest: true" -H "Accept: application/json" +# Установить бота (webhook-based, необязательно для polling) +$EXEC php occ talk:bot:install \ + -f webhook -f response -f event \ + 'Максимка' 'secret-key' 'http://webhook-url/endpoint' 'Описание' -# Сброс пароля пользователя -echo '1qaz!QAZ' | sudo -S docker exec -i -e OC_PASS='NewPassword' -u www-data nextcloud-aio-nextcloud php occ user:resetpassword --password-from-env maximka +# Список ботов в чате +$EXEC php occ talk:bot:list {TOKEN} + +# Добавить бота в чат +$EXEC php occ talk:bot:setup {BOT_ID} {CHAT_TOKEN} + +# Удалить бота из чата +$EXEC php occ talk:bot:remove {BOT_ID} {CHAT_TOKEN} + +# Удалить бота полностью +$EXEC php occ talk:bot:uninstall {BOT_ID} ``` -## Важные заметки - -1. **Webhook не работает** — Nextcloud Talk webhook для ботов требует background jobs и работает ненадёжно в AIO setup. Поэтому используем polling. - -2. **Join room обязательно** — перед отправкой сообщения от maximka нужно вызвать `POST .../room/{token}/participants/active` (join), иначе 401. - -3. **Фильтрация** — бот пропускает: - - Свои сообщения (actorId == "maximka") - - Сообщения от других ботов (actorType == "bots") - - Системные сообщения (messageType == "system") - -4. **Long polling** — Talk API поддерживает `lookIntoFuture=1&timeout=30` для эффективного ожидания новых сообщений. +--- ## TODO - [ ] Systemd сервис для автозапуска -- [ ] История контекста (несколько последних сообщений) - [ ] Поддержка нескольких чатов - [ ] Аватар для пользователя maximka +- [ ] Интеграция с основным Clawdbot (через hooks или sessions) +- [ ] Возможность выполнять команды (не только отвечать) diff --git a/projects/dttb/nextcloud-talk-bot/nextcloud-talk-bot.py b/projects/dttb/nextcloud-talk-bot/nextcloud-talk-bot.py new file mode 100644 index 0000000..b6f365d --- /dev/null +++ b/projects/dttb/nextcloud-talk-bot/nextcloud-talk-bot.py @@ -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()