From fbb31409dac4b81a679f5751cdad593317a7d439 Mon Sep 17 00:00:00 2001 From: Tony Tran Date: Mon, 14 Sep 2026 22:32:32 +0700 Subject: [PATCH] add files --- .env.example | 18 +++ .gitignore | 212 ++++++++++++++++++++++++++++ bot_app.py | 163 +++++++++++++++++++++ format_zalo.py | 120 ++++++++++++++++ hermes_client.py | 204 ++++++++++++++++++++++++++ requirements.txt | 3 + scripts/enable-hermes-api-server.sh | 33 +++++ zalo-hermes-bot.service | 22 +++ 8 files changed, 775 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 bot_app.py create mode 100644 format_zalo.py create mode 100644 hermes_client.py create mode 100644 requirements.txt create mode 100755 scripts/enable-hermes-api-server.sh create mode 100644 zalo-hermes-bot.service diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6ba74c5 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Zalo Bot token (bắt buộc) +BOT_TOKEN=your_zalo_bot_token + +# Hermes API Server (gateway) — OpenAI-compatible, full agent tools +# Bật: API_SERVER_ENABLED=true + API_SERVER_KEY trong ~/.hermes/.env rồi restart hermes-gateway +HERMES_API_BASE=http://127.0.0.1:8642/v1 +HERMES_API_KEY=change-me-local-dev +HERMES_MODEL=hermes-agent +HERMES_TIMEOUT=180 + +# Giữ bao nhiêu lượt user/assistant trong session Zalo → Hermes +HERMES_HISTORY_TURNS=12 + +# System prompt gắn vào mỗi request (tuỳ chọn) +HERMES_SYSTEM_PROMPT=Bạn đang chat với người dùng qua Zalo Bot. Trả lời ngắn gọn, tiếng Việt trừ khi họ hỏi tiếng khác. Không dùng markdown nặng (**, ##, [[wiki]]); ưu tiên plain text dễ đọc trên Zalo. + +# Backend: api (mặc định, API Server) | cli (hermes chat -q, chậm hơn, không giữ history tốt) +HERMES_BACKEND=api diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f50830 --- /dev/null +++ b/.gitignore @@ -0,0 +1,212 @@ +# Debug json +*.json +data/ +chroma_db/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ diff --git a/bot_app.py b/bot_app.py new file mode 100644 index 0000000..e6a6469 --- /dev/null +++ b/bot_app.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Zalo Bot → Hermes Agent (API Server). Không dùng support KB :8766.""" + +from __future__ import annotations + +import asyncio +import logging +import os +import warnings +from concurrent.futures import ThreadPoolExecutor + +from dotenv import load_dotenv +from pathlib import Path + +# Load .env TRƯỚC mọi import đọc os.environ (hermes_client / BOT_TOKEN) +load_dotenv(Path(__file__).resolve().parent / ".env") + +import zalo_bot + +from format_zalo import format_for_zalo +from hermes_client import ( + HERMES_API_BASE, + HERMES_BACKEND, + ask_hermes, + clear_history, + hermes_health, +) + +logging.getLogger("zalo_bot").setLevel(logging.ERROR) +warnings.filterwarnings("ignore") + +BOT_TOKEN = os.getenv("BOT_TOKEN") +if not BOT_TOKEN: + raise ValueError("BOT_TOKEN not found in .env") + +_HERMES_WORKERS = int(os.getenv("HERMES_WORKERS", "2")) +_executor = ThreadPoolExecutor(max_workers=max(1, _HERMES_WORKERS)) +_busy: set[str] = set() + + +def split_zalo_text(text: str, limit: int = 1800) -> list[str]: + text = (text or "").strip() + if not text: + return ["(không có nội dung)"] + if len(text) <= limit: + return [text] + parts: list[str] = [] + rest = text + while rest: + if len(rest) <= limit: + parts.append(rest) + break + cut = rest.rfind("\n", 0, limit) + if cut < limit // 3: + cut = rest.rfind(" ", 0, limit) + if cut < limit // 3: + cut = limit + parts.append(rest[:cut].rstrip()) + rest = rest[cut:].lstrip() + return parts or [text[:limit]] + + +def get_answer(chat_id: str, question: str) -> str: + q = (question or "").strip() + if not q: + return "Bạn vui lòng gửi nội dung cần hỏi nhé." + + if q.lower() in {"/reset", "/new", "reset", "xóa lịch sử", "xoa lich su"}: + clear_history(chat_id) + return "Đã xóa lịch sử hội thoại với Hermes cho chat này." + + result = ask_hermes(chat_id, q) + answer = (result.get("answer") or "").strip() + plain = format_for_zalo(answer) + return plain or answer + + +async def send_long_message(bot: zalo_bot.Bot, chat_id: str, text: str) -> None: + for part in split_zalo_text(text): + await bot.send_message(chat_id, part) + + +async def handle_user_message(bot: zalo_bot.Bot, chat_id: str, question: str) -> None: + """Chạy nền: Hermes → trả Zalo (không chặn long-poll).""" + key = str(chat_id) + if key in _busy: + await bot.send_message( + chat_id, + "Mình đang xử lý câu trước của bạn. Vui lòng đợi một chút…", + ) + return + _busy.add(key) + try: + try: + await bot.send_chat_action(chat_id, "typing") + except Exception: + pass + + loop = asyncio.get_running_loop() + answer = await loop.run_in_executor(_executor, get_answer, chat_id, question) + await send_long_message(bot, chat_id, answer) + print(f"[zalo] Sent to {chat_id} ({len(answer)} chars)") + except Exception as e: + print(f"[zalo] handle error for {chat_id}: {e}") + try: + await bot.send_message( + chat_id, + "Có lỗi khi nhờ Hermes trả lời. Bạn thử lại sau nhé.", + ) + except Exception: + pass + finally: + _busy.discard(key) + + +async def main() -> None: + health = hermes_health() + print(f"[hermes] backend={HERMES_BACKEND} base={HERMES_API_BASE} health={health}") + if not health.get("ok"): + print( + "[hermes] WARNING: API Server chưa sẵn sàng. " + "Bật API_SERVER_ENABLED=true + API_SERVER_KEY trong ~/.hermes/.env " + "rồi systemctl --user restart hermes-gateway " + "(listen http://127.0.0.1:8642)." + ) + + bot = zalo_bot.Bot(BOT_TOKEN) + async with bot: + me = await bot.get_me() + print( + f"[zalo] started as {getattr(me, 'account_name', None) or me} " + f"id={getattr(me, 'id', None)}; listening…" + ) + + while True: + try: + update = await bot.get_update(timeout=30) + if not (update and update.message and update.message.text): + continue + + chat_id = update.message.chat.id + question = update.message.text + print(f"[zalo] Message from {chat_id}: {question}") + asyncio.create_task(handle_user_message(bot, chat_id, question)) + + except asyncio.CancelledError: + raise + except KeyboardInterrupt: + raise + except zalo_bot.error.ZaloError: + await asyncio.sleep(1) + except Exception as e: + print(f"[zalo] Unexpected error: {e}") + await asyncio.sleep(2) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\n[zalo] stopped (Ctrl+C)") + finally: + _executor.shutdown(wait=False, cancel_futures=True) diff --git a/format_zalo.py b/format_zalo.py new file mode 100644 index 0000000..9b46250 --- /dev/null +++ b/format_zalo.py @@ -0,0 +1,120 @@ +"""Chuẩn hóa câu trả lời Hermes → text thuần cho Zalo (không markdown).""" +from __future__ import annotations + +import re +import unicodedata + +_MD_LINK = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") +_BOLD = re.compile(r"\*\*([^*]+)\*\*") +_ITALIC = re.compile(r"(?\s?") +_HR = re.compile(r"(?m)^(?:-{3,}|\*{3,}|_{3,})\s*$") +_HTML_TAG = re.compile(r"]*>") +_WIKI_OR_REF = re.compile(r"\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|([^\]]+))?\]\]") +_MULTI_NL = re.compile(r"\n{3,}") +_MULTI_SPACE = re.compile(r"[ \t]{2,}") +_DASHY = re.compile(r"\s*[—–]\s*") + +# Lỗi chính tả / khoảng trắng tiếng Việt thường gặp từ model +_TYPO_FIXES: list[tuple[re.Pattern[str], str]] = [ + (re.compile(r"\s+([,.!?;:…])"), r"\1"), + (re.compile(r"([(\[])\s+"), r"\1"), + (re.compile(r"\s+([)\]])"), r"\1"), + (re.compile(r"(?i)\bko\b"), "không"), + (re.compile(r"(?i)\bdc\b"), "được"), + (re.compile(r"(?i)\bđc\b"), "được"), + (re.compile(r"(?i)\bkq\b"), "kết quả"), + (re.compile(r"(?i)\bntn\b"), "như thế nào"), + (re.compile(r"(?i)\bntn\b"), "như thế nào"), + (re.compile(r"(?i)\bmn\b"), "mọi người"), + (re.compile(r"(?i)\bbj\b"), "bây giờ"), + (re.compile(r"(?i)\bhqua\b"), "hôm qua"), + (re.compile(r"(?i)\bhnay\b"), "hôm nay"), + (re.compile(r"(?i)\bsdt\b"), "SĐT"), + (re.compile(r"(?i)\bvs\b"), "với"), + (re.compile(r"\u00a0"), " "), +] + + +def _nfc(text: str) -> str: + return unicodedata.normalize("NFC", text or "") + + +def _strip_markdown(text: str) -> str: + t = text + t = _CODE_FENCE.sub(lambda m: "\n".join( + line for line in m.group(0).splitlines()[1:-1] + ), t) + t = _HTML_TAG.sub("", t) + t = _WIKI_OR_REF.sub(lambda m: (m.group(2) or m.group(1) or "").strip(), t) + t = _MD_LINK.sub(lambda m: m.group(1).strip() or m.group(2).strip(), t) + t = _BOLD.sub(r"\1", t) + t = _ITALIC.sub(r"\1", t) + t = _STRIKE.sub(r"\1", t) + t = _INLINE_CODE.sub(r"\1", t) + t = _HEADING.sub("", t) + t = _BLOCKQUOTE.sub("", t) + t = _HR.sub("", t) + t = _BULLET.sub("• ", t) + t = _ORDERED.sub(lambda m: f"{m.group(0).split('.')[0]}. ", t) + return t + + +def _fix_vietnamese_typos(text: str) -> str: + t = text + for pat, repl in _TYPO_FIXES: + t = pat.sub(repl, t) + # khoảng trắng quanh dấu tiếng Việt kiểu "ạ ," / "không ?" + t = re.sub(r"([^\s])\s+([?!…])", r"\1\2", t) + return t + + +def _tidy_lines(text: str) -> str: + lines: list[str] = [] + for line in text.splitlines(): + line = _MULTI_SPACE.sub(" ", line).strip() + if re.fullmatch(r"[—–\-.…\s]*", line or ""): + continue + if "—" in line or "–" in line: + parts = [p.strip() for p in _DASHY.split(line) if p.strip()] + if len(parts) > 1 and any(len(p) > 12 for p in parts): + lines.extend(parts) + continue + line = " ".join(parts) + if line: + lines.append(line) + t = "\n".join(lines) + t = _MULTI_NL.sub("\n\n", t).strip() + t = re.sub(r"\s+\.\s*$", ".", t) + t = re.sub(r"\.{2,}", "…", t) + t = re.sub(r"(?m)^[.\s]+$", "", t) + return _MULTI_NL.sub("\n\n", t).strip() + + +def format_for_zalo(text: str, *, max_chars: int = 1800) -> str: + """Bỏ markdown, chuẩn hóa khoảng trắng / lỗi chính tả VN, cắt nếu quá dài.""" + t = _nfc((text or "").strip()) + if not t: + return t + + t = _strip_markdown(t) + t = _fix_vietnamese_typos(t) + t = _tidy_lines(t) + + if max_chars and len(t) > max_chars: + cut = t.rfind("\n", 0, max_chars) + if cut < max_chars // 3: + cut = t.rfind(". ", 0, max_chars) + if cut > 0: + cut += 1 + if cut < max_chars // 3: + cut = max_chars + t = t[:cut].rstrip(" ,;:") + "…" + + return t.strip() diff --git a/hermes_client.py b/hermes_client.py new file mode 100644 index 0000000..f9f3610 --- /dev/null +++ b/hermes_client.py @@ -0,0 +1,204 @@ +"""Client tới Hermes Agent — kênh Zalo riêng (không phải support KB). + +Backend mặc định: Hermes API Server (OpenAI-compatible) trên gateway :8642. +Đọc env lúc gọi (không snapshot lúc import) để load_dotenv() luôn có hiệu lực. +""" +from __future__ import annotations + +import os +import subprocess +from collections import defaultdict, deque +from typing import Any, Deque, Dict, List + +import requests + +MISS = ( + "Hermes chưa trả lời được lúc này. Bạn thử lại sau hoặc diễn đạt lại câu hỏi nhé." +) + +# chat_id -> deque of {role, content} — maxlen set on first use / refresh +_HISTORIES: Dict[str, Deque[dict[str, str]]] = {} + + +def _cfg() -> dict[str, Any]: + turns = int(os.environ.get("HERMES_HISTORY_TURNS", "12")) + return { + "base": os.environ.get("HERMES_API_BASE", "http://127.0.0.1:8642/v1").rstrip("/"), + "key": (os.environ.get("HERMES_API_KEY") or "").strip(), + "model": os.environ.get("HERMES_MODEL", "hermes-agent"), + "timeout": float(os.environ.get("HERMES_TIMEOUT", "180")), + "turns": turns, + "backend": (os.environ.get("HERMES_BACKEND") or "api").strip().lower(), + "system": ( + os.environ.get("HERMES_SYSTEM_PROMPT") + or ( + "Bạn đang chat với người dùng qua Zalo Bot. " + "Trả lời ngắn gọn, tiếng Việt trừ khi họ hỏi tiếng khác. " + "Không dùng markdown nặng; ưu tiên plain text dễ đọc trên Zalo." + ) + ).strip(), + } + + +# Compat exports used by bot_app logging +def _refresh_exports() -> None: + global HERMES_API_BASE, HERMES_BACKEND + c = _cfg() + HERMES_API_BASE = c["base"] + HERMES_BACKEND = c["backend"] + + +_refresh_exports() + + +def _auth_headers(key: str) -> dict[str, str]: + h = {"Content-Type": "application/json", "User-Agent": "zalo-hermes-bot/0.1"} + if key: + h["Authorization"] = f"Bearer {key}" + return h + + +def _history_deque(chat_id: str, turns: int) -> Deque[dict[str, str]]: + key = str(chat_id) + maxlen = max(2, turns * 2) + d = _HISTORIES.get(key) + if d is None or d.maxlen != maxlen: + old = list(d) if d is not None else [] + d = deque(old, maxlen=maxlen) + _HISTORIES[key] = d + return d + + +def hermes_health() -> dict[str, Any]: + c = _cfg() + _refresh_exports() + if c["backend"] == "cli": + try: + p = subprocess.run( + ["hermes", "--version"], + capture_output=True, + text=True, + timeout=10, + ) + return { + "ok": p.returncode == 0, + "backend": "cli", + "body": (p.stdout or p.stderr or "")[:200], + } + except Exception as e: + return {"ok": False, "backend": "cli", "error": str(e)} + + candidates = [ + f"{c['base']}/models", + c["base"].replace("/v1", "") + "/health", + "http://127.0.0.1:8642/health", + ] + last: dict[str, Any] = {"ok": False, "backend": "api"} + for url in candidates: + try: + r = requests.get(url, timeout=5, headers=_auth_headers(c["key"])) + ctype = r.headers.get("content-type", "") + body: Any = r.json() if "json" in ctype else r.text[:200] + last = { + "ok": r.status_code < 400, + "backend": "api", + "url": url, + "status_code": r.status_code, + "body": body, + "api_key_len": len(c["key"]), + } + if last["ok"]: + return last + except Exception as e: + last = {"ok": False, "backend": "api", "url": url, "error": str(e)} + return last + + +def _history_for(chat_id: str, turns: int) -> List[dict[str, str]]: + return list(_history_deque(chat_id, turns)) + + +def _remember(chat_id: str, role: str, content: str, turns: int) -> None: + _history_deque(chat_id, turns).append({"role": role, "content": content}) + + +def clear_history(chat_id: str) -> None: + _HISTORIES.pop(str(chat_id), None) + + +def ask_hermes_api(chat_id: str, message: str) -> dict[str, Any]: + c = _cfg() + msg = (message or "").strip() + if not msg: + return {"answer": MISS, "meta": "empty"} + if not c["key"]: + print("HERMES CLIENT: HERMES_API_KEY empty — load_dotenv before import, or export env") + return {"answer": MISS, "meta": "missing api key"} + + messages: list[dict[str, str]] = [] + if c["system"]: + messages.append({"role": "system", "content": c["system"]}) + messages.extend(_history_for(chat_id, c["turns"])) + messages.append({"role": "user", "content": msg}) + + url = f"{c['base']}/chat/completions" + try: + r = requests.post( + url, + json={ + "model": c["model"], + "messages": messages, + "stream": False, + }, + timeout=c["timeout"], + headers=_auth_headers(c["key"]), + ) + if r.status_code == 401: + print( + "HERMES CLIENT: unauthorized — " + f"api_key_len={len(c['key'])} (must match API_SERVER_KEY)" + ) + return {"answer": MISS, "meta": "unauthorized"} + r.raise_for_status() + data = r.json() + choices = data.get("choices") or [] + content = "" + if choices: + content = ((choices[0].get("message") or {}).get("content") or "").strip() + if not content: + return {"answer": MISS, "meta": "empty response"} + _remember(chat_id, "user", msg, c["turns"]) + _remember(chat_id, "assistant", content, c["turns"]) + return {"answer": content, "meta": "api"} + except Exception as e: + print(f"HERMES CLIENT ERROR: {e}") + return {"answer": MISS, "meta": f"error: {e}"} + + +def ask_hermes_cli(message: str) -> dict[str, Any]: + c = _cfg() + msg = (message or "").strip() + if not msg: + return {"answer": MISS, "meta": "empty"} + try: + p = subprocess.run( + ["hermes", "chat", "-q", msg], + capture_output=True, + text=True, + timeout=c["timeout"], + ) + out = (p.stdout or "").strip() or (p.stderr or "").strip() + if p.returncode != 0 or not out: + print(f"HERMES CLI fail rc={p.returncode}: {out[:300]}") + return {"answer": MISS, "meta": f"cli rc={p.returncode}"} + return {"answer": out, "meta": "cli"} + except Exception as e: + print(f"HERMES CLI ERROR: {e}") + return {"answer": MISS, "meta": f"cli error: {e}"} + + +def ask_hermes(chat_id: str, message: str) -> dict[str, Any]: + c = _cfg() + if c["backend"] == "cli": + return ask_hermes_cli(message) + return ask_hermes_api(chat_id, message) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6473ab5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +python-dotenv +python-zalo-bot +requests diff --git a/scripts/enable-hermes-api-server.sh b/scripts/enable-hermes-api-server.sh new file mode 100755 index 0000000..6be80c0 --- /dev/null +++ b/scripts/enable-hermes-api-server.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Enable Hermes API Server on the local gateway (one-time helper). +# Docs: https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server +set -euo pipefail + +HERMES_ENV="${HERMES_HOME:-$HOME/.hermes}/.env" +KEY="${1:-change-me-local-dev}" + +mkdir -p "$(dirname "$HERMES_ENV")" +touch "$HERMES_ENV" + +set_kv() { + local k="$1" v="$2" + if grep -q "^${k}=" "$HERMES_ENV" 2>/dev/null; then + sed -i "s|^${k}=.*|${k}=${v}|" "$HERMES_ENV" + else + printf '%s=%s\n' "$k" "$v" >>"$HERMES_ENV" + fi +} + +set_kv API_SERVER_ENABLED true +set_kv API_SERVER_KEY "$KEY" + +echo "Updated $HERMES_ENV" +echo " API_SERVER_ENABLED=true" +echo " API_SERVER_KEY=${KEY}" +echo +echo "Restart gateway:" +echo " systemctl --user restart hermes-gateway" +echo "Smoke:" +echo " curl -sS http://127.0.0.1:8642/v1/models -H \"Authorization: Bearer ${KEY}\"" +echo +echo "Put the same key in zalo-hermes-bot/.env as HERMES_API_KEY" diff --git a/zalo-hermes-bot.service b/zalo-hermes-bot.service new file mode 100644 index 0000000..bc4f915 --- /dev/null +++ b/zalo-hermes-bot.service @@ -0,0 +1,22 @@ +[Unit] +Description=Orange Pi VN Zalo ↔ Hermes Agent bridge (python-zalo-bot) +After=network-online.target +Wants=network-online.target +StartLimitIntervalSec=0 + +[Service] +Type=simple +WorkingDirectory=/home/orangepi/workspace/zalo-hermes-bot +EnvironmentFile=-/home/orangepi/workspace/zalo-hermes-bot/.env +Environment=PYTHONUNBUFFERED=1 +ExecStart=/home/orangepi/workspace/zalo-hermes-bot/.venv/bin/python /home/orangepi/workspace/zalo-hermes-bot/bot_app.py +Restart=always +RestartSec=5 +KillMode=mixed +KillSignal=SIGTERM +TimeoutStopSec=30 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=default.target