diff --git a/.gitignore b/.gitignore index 1dcd92a..dd51472 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ deploy/secrets/ *.htpasswd* .env .env.local +__pycache__/ +*.pyc diff --git a/.memory/worklog.json b/.memory/worklog.json index aef44db..66868dc 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,19 +1,5 @@ { "entries": [ - { - "files_changed": 1, - "hash": "e59742e", - "message": "auto-save 2026-05-07 11:59 (~1)", - "ts": "2026-05-07T11:59:40+08:00", - "type": "commit" - }, - { - "files_changed": 1, - "hash": "3dee234", - "message": "auto-save 2026-05-07 12:05 (~1)", - "ts": "2026-05-07T12:05:11+08:00", - "type": "commit" - }, { "files_changed": 1, "hash": "18087a5", @@ -3493,6 +3479,19 @@ "type": "session-heartbeat", "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 2 项未提交变更 · 最近提交:auto-save 2026-05-09 16:06 (~5)", "files_changed": 2 + }, + { + "ts": "2026-05-09T16:12:31+08:00", + "type": "commit", + "message": "auto-save 2026-05-09 16:12 (~4)", + "hash": "e74787b", + "files_changed": 4 + }, + { + "ts": "2026-05-09T08:15:51Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 5 项未提交变更 · 最近提交:auto-save 2026-05-09 16:12 (~4)", + "files_changed": 5 } ] } diff --git a/.project.json b/.project.json index 6ab34c6..5f624b5 100644 --- a/.project.json +++ b/.project.json @@ -4,6 +4,10 @@ { "category" : "Hermes Glass UI 个人版(hermes.kang-kang.com, 2026-04-21)", "entry" : "Hermes Glass UI 个人版(hermes.kang-kang.com, 2026-04-21)" + }, + { + "category" : "Feishu 自建应用", + "entry" : "Hermes Feishu App cli_a974771369bb1bc3" } ], "description" : "Fork from 20260414-hermes-glass-ui, 单用户个人 VPS 部署 (hermes.kang-kang.com)", diff --git a/README.md b/README.md index dbc7be0..8c47550 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,15 @@ - **模型**: Google Gemini 3 Pro Preview (via OpenRouter) - **登录**: 单用户 `kang` +## 飞书桥接 + +`server/feishu_bridge.py` 提供飞书双向桥接: + +- `POST /feishu/events`: 飞书事件回调 → Hermes → 飞书回复 +- `POST /feishu/notify`: Hermes / 内部系统主动推送到飞书 + +凭证通过部署环境变量配置,详见 `server/feishu-bridge.env.example`;不要把 App Secret、Hermes API key 或通知 token 写入仓库。 + ## 与公司版的差异 | 维度 | 公司版 (hermes.milejoy.com) | 个人版 (hermes.kang-kang.com) | diff --git a/RULES.md b/RULES.md index a0735b3..eeea149 100644 --- a/RULES.md +++ b/RULES.md @@ -8,6 +8,7 @@ - 发布状态:已部署 - 主站 / 前端:https://hermes.kang-kang.com - API / 后端:同域 `/api/v1` 转发到 LXC `hermes-personal` 内的 `hermes-agent:8642` +- 飞书桥接:源码在 `server/feishu_bridge.py`;线上计划开放 `/feishu/events`(飞书事件)和 `/feishu/notify`(主动通知) - 文档 / 解析:https://styles.kang-kang.com - 管理后台:待定 - 代码仓:https://git.kang-kang.com/kangwan/hermes-glass-ui-personal @@ -26,7 +27,15 @@ - 部署完成后,`RULES.md` 和 `.project.json` 必须同一次任务一起更新 ## 环境变量 -- 待补充 +- 飞书桥接服务: + - `FEISHU_APP_ID` + - `FEISHU_APP_SECRET`(敏感,不入库) + - `FEISHU_VERIFICATION_TOKEN`(可选,建议配置) + - `FEISHU_DEFAULT_RECEIVE_ID` / `FEISHU_DEFAULT_RECEIVE_ID_TYPE`(主动通知默认目标) + - `FEISHU_NOTIFY_TOKEN`(主动通知内部鉴权,敏感,不入库) + - `HERMES_API_BASE` + - `HERMES_API_KEY`(敏感,不入库) + - `HERMES_MODEL` ## 规则 - 不允许编造不存在的部署域名、账号、密码 @@ -34,4 +43,6 @@ - 任何部署或域名变化,都要先改元数据,再视为任务完成 ## 注意事项 -- 待补充 +- 飞书 App Secret、Hermes API key、主动通知 token 只能放部署环境或忽略的 secrets 文件,不允许写入跟踪文件 +- 当前飞书桥接版本按明文事件回调处理;如果飞书后台开启事件加密,需要先补充解密支持 +- 主站有 cookie 门禁;部署飞书桥接时必须在 nginx/Coolify 对 `/feishu/events` 放行,否则飞书服务器无法完成 URL 校验和事件投递 diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..0a2c2ca --- /dev/null +++ b/server/README.md @@ -0,0 +1,27 @@ +# Hermes Feishu Bridge + +双向桥接服务: + +- `POST /feishu/events`:飞书事件回调。收到文本消息后后台调用 Hermes,再发回飞书。 +- `POST /feishu/notify`:Hermes 或内部系统主动通知飞书。必须带 `Authorization: Bearer $FEISHU_NOTIFY_TOKEN` 或 `X-Hermes-Feishu-Token`。 +- `GET /health`:健康检查。 + +## 凭证 + +不要把 `FEISHU_APP_SECRET`、`HERMES_API_KEY`、`FEISHU_NOTIFY_TOKEN` 写入仓库。线上使用 `/etc/hermes-feishu-bridge.env` 或同等级别的部署密钥文件。 + +## 飞书后台配置 + +1. 给自建应用开通消息相关权限,例如接收消息事件、获取与发送单聊/群组消息。 +2. 事件订阅里添加请求地址:`https://hermes.kang-kang.com/feishu/events`。 +3. 如果启用事件加密,需要先给本服务补充解密支持;当前版本按明文事件回调处理。 +4. 建议配置 `FEISHU_VERIFICATION_TOKEN`,并保持和飞书后台一致。 + +## 主动通知示例 + +```bash +curl -X POST https://hermes.kang-kang.com/feishu/notify \ + -H "Authorization: Bearer $FEISHU_NOTIFY_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"receive_id_type":"chat_id","receive_id":"oc_xxx","text":"任务完成"}' +``` diff --git a/server/feishu-bridge.env.example b/server/feishu-bridge.env.example new file mode 100644 index 0000000..f4f1f60 --- /dev/null +++ b/server/feishu-bridge.env.example @@ -0,0 +1,17 @@ +FEISHU_APP_ID=cli_xxxxxxxxxxxxxxxx +FEISHU_APP_SECRET=replace-with-secret +FEISHU_VERIFICATION_TOKEN= +FEISHU_DEFAULT_RECEIVE_ID= +FEISHU_DEFAULT_RECEIVE_ID_TYPE=chat_id +FEISHU_NOTIFY_TOKEN=replace-with-random-internal-token +FEISHU_ALLOWED_CHAT_IDS= +FEISHU_REPLY_IN_THREAD=false + +HERMES_API_BASE=http://127.0.0.1:8642/v1 +HERMES_API_KEY=replace-with-hermes-api-server-key +HERMES_MODEL=gemini-3-pro-preview +HERMES_SYSTEM_PROMPT=你是爱马仕 Hermes。你通过飞书与用户对话,回答要直接、简洁、可执行。 + +FEISHU_BRIDGE_PORT=8787 +FEISHU_BRIDGE_TIMEOUT=60 +LOG_LEVEL=INFO diff --git a/server/feishu-bridge.service.example b/server/feishu-bridge.service.example new file mode 100644 index 0000000..4fe1fb6 --- /dev/null +++ b/server/feishu-bridge.service.example @@ -0,0 +1,16 @@ +[Unit] +Description=Hermes Feishu Bridge +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/opt/hermes-feishu-bridge +EnvironmentFile=/etc/hermes-feishu-bridge.env +ExecStart=/usr/bin/python3 /opt/hermes-feishu-bridge/feishu_bridge.py +Restart=always +RestartSec=3 +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target diff --git a/server/feishu_bridge.py b/server/feishu_bridge.py new file mode 100644 index 0000000..afd294b --- /dev/null +++ b/server/feishu_bridge.py @@ -0,0 +1,384 @@ +#!/usr/bin/env python3 +"""Feishu <-> Hermes bridge. + +This service intentionally uses only the Python standard library so it can run +next to the existing no-build Hermes UI deployment without adding package +management to the project. +""" + +from __future__ import annotations + +import json +import logging +import os +import threading +import time +import traceback +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + + +FEISHU_API_BASE = "https://open.feishu.cn/open-apis" + + +def _env(name: str, default: str = "") -> str: + return os.environ.get(name, default).strip() + + +class Config: + feishu_app_id = _env("FEISHU_APP_ID") + feishu_app_secret = _env("FEISHU_APP_SECRET") + feishu_verification_token = _env("FEISHU_VERIFICATION_TOKEN") + feishu_default_receive_id = _env("FEISHU_DEFAULT_RECEIVE_ID") + feishu_default_receive_id_type = _env("FEISHU_DEFAULT_RECEIVE_ID_TYPE", "chat_id") + notify_token = _env("FEISHU_NOTIFY_TOKEN") + allowed_chat_ids = { + item.strip() + for item in _env("FEISHU_ALLOWED_CHAT_IDS").split(",") + if item.strip() + } + reply_in_thread = _env("FEISHU_REPLY_IN_THREAD", "false").lower() in {"1", "true", "yes"} + + hermes_api_base = _env("HERMES_API_BASE", "http://127.0.0.1:8642/v1").rstrip("/") + hermes_api_key = _env("HERMES_API_KEY") + hermes_model = _env("HERMES_MODEL", "gemini-3-pro-preview") + hermes_system_prompt = _env( + "HERMES_SYSTEM_PROMPT", + "你是爱马仕 Hermes。你通过飞书与用户对话,回答要直接、简洁、可执行。", + ) + request_timeout = float(_env("FEISHU_BRIDGE_TIMEOUT", "60")) + port = int(_env("PORT", _env("FEISHU_BRIDGE_PORT", "8787"))) + + +class TokenCache: + def __init__(self) -> None: + self._lock = threading.Lock() + self._token = "" + self._expires_at = 0.0 + + def get(self) -> str: + with self._lock: + if self._token and time.time() < self._expires_at - 300: + return self._token + + if not Config.feishu_app_id or not Config.feishu_app_secret: + raise RuntimeError("FEISHU_APP_ID and FEISHU_APP_SECRET are required") + + payload = { + "app_id": Config.feishu_app_id, + "app_secret": Config.feishu_app_secret, + } + data = http_json( + "POST", + f"{FEISHU_API_BASE}/auth/v3/tenant_access_token/internal", + payload, + ) + if data.get("code") != 0: + raise RuntimeError(f"Feishu token error: {data}") + + self._token = str(data["tenant_access_token"]) + self._expires_at = time.time() + int(data.get("expire", 7200)) + return self._token + + +token_cache = TokenCache() +seen_events: dict[str, float] = {} +seen_lock = threading.Lock() + + +def http_json( + method: str, + url: str, + payload: dict[str, Any], + headers: dict[str, str] | None = None, + timeout: float | None = None, +) -> dict[str, Any]: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + url, + data=body, + method=method, + headers={ + "Content-Type": "application/json; charset=utf-8", + **(headers or {}), + }, + ) + try: + with urllib.request.urlopen(req, timeout=timeout or Config.request_timeout) as resp: + raw = resp.read().decode("utf-8") + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"HTTP {exc.code} {url}: {raw}") from exc + + if not raw: + return {} + return json.loads(raw) + + +def json_text(value: Any) -> str: + if isinstance(value, str): + try: + value = json.loads(value) + except json.JSONDecodeError: + return value + if isinstance(value, dict): + for key in ("text", "title", "content"): + text = value.get(key) + if isinstance(text, str) and text.strip(): + return text.strip() + return "" + + +def verify_callback_token(body: dict[str, Any]) -> bool: + expected = Config.feishu_verification_token + if not expected: + return True + token = body.get("token") or body.get("header", {}).get("token") + return token == expected + + +def remember_event(event_id: str) -> bool: + if not event_id: + return True + now = time.time() + with seen_lock: + stale = [key for key, ts in seen_events.items() if now - ts > 3600] + for key in stale: + seen_events.pop(key, None) + if event_id in seen_events: + return False + seen_events[event_id] = now + return True + + +def handle_feishu_event(body: dict[str, Any]) -> tuple[int, dict[str, Any]]: + if "encrypt" in body: + return 400, { + "code": 400, + "msg": "encrypted callbacks are not enabled; disable Feishu event encryption or add decrypt support", + } + + if body.get("type") == "url_verification": + if not verify_callback_token(body): + return 403, {"code": 403, "msg": "invalid verification token"} + return 200, {"challenge": body.get("challenge", "")} + + if not verify_callback_token(body): + return 403, {"code": 403, "msg": "invalid verification token"} + + event_type = body.get("header", {}).get("event_type") or body.get("event", {}).get("type") + event_id = body.get("header", {}).get("event_id", "") + if event_type != "im.message.receive_v1": + return 200, {"code": 0, "msg": "ignored"} + if not remember_event(event_id): + return 200, {"code": 0, "msg": "duplicate"} + + threading.Thread(target=process_message_event, args=(body,), daemon=True).start() + return 200, {"code": 0, "msg": "ok"} + + +def process_message_event(body: dict[str, Any]) -> None: + try: + event = body.get("event", {}) + sender = event.get("sender", {}) + if sender.get("sender_type") == "app": + return + + message = event.get("message", {}) + message_type = message.get("message_type") + chat_id = message.get("chat_id", "") + message_id = message.get("message_id", "") + if Config.allowed_chat_ids and chat_id not in Config.allowed_chat_ids: + logging.info("ignored message from disallowed chat_id=%s", chat_id) + return + + if message_type != "text": + send_feishu_text(chat_id, "我现在只支持处理文本消息。", message_id=message_id) + return + + text = json_text(message.get("content")) + if not text: + send_feishu_text(chat_id, "我没有读到文本内容。", message_id=message_id) + return + + answer = ask_hermes(text, event) + send_feishu_text(chat_id, answer, message_id=message_id) + except Exception: + logging.error("failed to process Feishu event:\n%s", traceback.format_exc()) + + +def ask_hermes(text: str, event: dict[str, Any]) -> str: + payload = { + "model": Config.hermes_model, + "stream": False, + "messages": [ + {"role": "system", "content": Config.hermes_system_prompt}, + { + "role": "user", + "content": ( + "以下消息来自飞书。\n" + f"chat_id: {event.get('message', {}).get('chat_id', '')}\n\n" + f"{text}" + ), + }, + ], + } + headers = {} + if Config.hermes_api_key: + headers["Authorization"] = f"Bearer {Config.hermes_api_key}" + data = http_json( + "POST", + f"{Config.hermes_api_base}/chat/completions", + payload, + headers=headers, + ) + try: + content = data["choices"][0]["message"]["content"] + except (KeyError, IndexError, TypeError) as exc: + raise RuntimeError(f"Unexpected Hermes response: {data}") from exc + return str(content).strip() or "Hermes 没有返回内容。" + + +def send_feishu_text( + receive_id: str, + text: str, + receive_id_type: str = "chat_id", + message_id: str = "", +) -> dict[str, Any]: + if not receive_id: + raise RuntimeError("receive_id is required") + + token = token_cache.get() + chunks = split_text(text) + result: dict[str, Any] = {} + for chunk in chunks: + content = json.dumps({"text": chunk}, ensure_ascii=False) + if Config.reply_in_thread and message_id: + url = f"{FEISHU_API_BASE}/im/v1/messages/{message_id}/reply" + payload = {"msg_type": "text", "content": content} + else: + url = f"{FEISHU_API_BASE}/im/v1/messages?receive_id_type={receive_id_type}" + payload = {"receive_id": receive_id, "msg_type": "text", "content": content} + result = http_json( + "POST", + url, + payload, + headers={"Authorization": f"Bearer {token}"}, + ) + if result.get("code") not in (None, 0): + raise RuntimeError(f"Feishu send error: {result}") + return result + + +def split_text(text: str, limit: int = 3800) -> list[str]: + text = text.strip() + if not text: + return [""] + chunks: list[str] = [] + rest = text + while len(rest) > limit: + cut = rest.rfind("\n", 0, limit) + if cut < limit // 2: + cut = limit + chunks.append(rest[:cut].strip()) + rest = rest[cut:].strip() + chunks.append(rest) + return chunks + + +def handle_notify(headers: dict[str, str], body: dict[str, Any]) -> tuple[int, dict[str, Any]]: + expected = Config.notify_token + if not expected: + return 503, {"code": 503, "msg": "FEISHU_NOTIFY_TOKEN is not configured"} + + auth = headers.get("authorization", "") + token = headers.get("x-hermes-feishu-token", "") + if auth.lower().startswith("bearer "): + token = auth[7:].strip() + if token != expected: + return 401, {"code": 401, "msg": "unauthorized"} + + text = str(body.get("text", "")).strip() + if not text: + return 400, {"code": 400, "msg": "text is required"} + receive_id = str(body.get("receive_id") or Config.feishu_default_receive_id).strip() + receive_id_type = str( + body.get("receive_id_type") or Config.feishu_default_receive_id_type + ).strip() + if not receive_id: + return 400, {"code": 400, "msg": "receive_id is required"} + + result = send_feishu_text(receive_id, text, receive_id_type=receive_id_type) + return 200, {"code": 0, "msg": "ok", "feishu": result} + + +class Handler(BaseHTTPRequestHandler): + server_version = "HermesFeishuBridge/1.0" + + def do_GET(self) -> None: + if self.path == "/health": + self.send_json(200, {"ok": True, "service": "feishu-bridge"}) + return + self.send_json(404, {"code": 404, "msg": "not found"}) + + def do_POST(self) -> None: + try: + body = self.read_json() + headers = {key.lower(): value for key, value in self.headers.items()} + if self.path == "/feishu/events": + status, payload = handle_feishu_event(body) + elif self.path == "/feishu/notify": + status, payload = handle_notify(headers, body) + else: + status, payload = 404, {"code": 404, "msg": "not found"} + except Exception as exc: + logging.error("request failed:\n%s", traceback.format_exc()) + status, payload = 500, {"code": 500, "msg": str(exc)} + self.send_json(status, payload) + + def read_json(self) -> dict[str, Any]: + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length).decode("utf-8") if length else "{}" + if not raw: + return {} + data = json.loads(raw) + if not isinstance(data, dict): + raise ValueError("JSON object is required") + return data + + def send_json(self, status: int, payload: dict[str, Any]) -> None: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_message(self, fmt: str, *args: Any) -> None: + logging.info("%s - %s", self.address_string(), fmt % args) + + +def main() -> None: + logging.basicConfig( + level=os.environ.get("LOG_LEVEL", "INFO"), + format="%(asctime)s %(levelname)s %(message)s", + ) + missing = [ + name + for name, value in { + "FEISHU_APP_ID": Config.feishu_app_id, + "FEISHU_APP_SECRET": Config.feishu_app_secret, + }.items() + if not value + ] + if missing: + logging.warning("missing required env: %s", ", ".join(missing)) + httpd = ThreadingHTTPServer(("0.0.0.0", Config.port), Handler) + logging.info("Feishu bridge listening on :%s", Config.port) + httpd.serve_forever() + + +if __name__ == "__main__": + main()