add files

This commit is contained in:
admin committed 2026-09-14 22:32:32 +07:00
1 parent ab8adbfa12
commit fbb31409da
8 files changed
+775

No files matched your search

+18
View File
@@ -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
+212
View File
@@ -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__/
+163
View File
@@ -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)
+120
View File
@@ -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"(?<!\*)\*([^*]+)\*(?!\*)")
_STRIKE = re.compile(r"~~([^~]+)~~")
_CODE_FENCE = re.compile(r"```[\s\S]*?```")
_INLINE_CODE = re.compile(r"`([^`]+)`")
_HEADING = re.compile(r"(?m)^#{1,6}\s+")
_BULLET = re.compile(r"(?m)^[\-\*]\s+")
_ORDERED = re.compile(r"(?m)^\d+\.\s+")
_BLOCKQUOTE = re.compile(r"(?m)^>\s?")
_HR = re.compile(r"(?m)^(?:-{3,}|\*{3,}|_{3,})\s*$")
_HTML_TAG = re.compile(r"</?[a-zA-Z][^>]*>")
_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()
+204
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
python-dotenv
python-zalo-bot
requests
+33
View File
@@ -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"
+22
View File
@@ -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