diff --git a/.memory/worklog.json b/.memory/worklog.json index 1f56939..aaf82d5 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,26 +1,5 @@ { "entries": [ - { - "files_changed": 1, - "hash": "e1ac8cd", - "message": "auto-save 2026-05-07 12:46 (~1)", - "ts": "2026-05-07T12:46:08+08:00", - "type": "commit" - }, - { - "files_changed": 1, - "hash": "9a789b4", - "message": "auto-save 2026-05-07 12:51 (~1)", - "ts": "2026-05-07T12:51:59+08:00", - "type": "commit" - }, - { - "files_changed": 1, - "hash": "d44069d", - "message": "auto-save 2026-05-07 12:57 (~1)", - "ts": "2026-05-07T12:57:51+08:00", - "type": "commit" - }, { "files_changed": 1, "hash": "8eed9ec", @@ -3489,6 +3468,25 @@ "message": "auto-save 2026-05-09 16:29 (~1)", "hash": "bdb8fe7", "files_changed": 1 + }, + { + "ts": "2026-05-09T16:35:00+08:00", + "type": "commit", + "message": "auto-save 2026-05-09 16:34 (~4)", + "hash": "211ce9d", + "files_changed": 4 + }, + { + "ts": "2026-05-09T08:35:52Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:auto-save 2026-05-09 16:34 (~4)", + "files_changed": 1 + }, + { + "ts": "2026-05-09T08:38:27Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 2 项未提交变更 · 最近提交:auto-save 2026-05-09 16:34 (~4)", + "files_changed": 2 } ] } diff --git a/.project.json b/.project.json index f7d38a4..3348ee3 100644 --- a/.project.json +++ b/.project.json @@ -8,6 +8,10 @@ { "category" : "Feishu 自建应用", "entry" : "Hermes Feishu App cli_a974771369bb1bc3" + }, + { + "category" : "Feishu 自建应用", + "entry" : "Hermes Feishu App cli_a97764e101b95be9" } ], "description" : "Fork from 20260414-hermes-glass-ui, 单用户个人 VPS 部署 (hermes.kang-kang.com)", @@ -39,6 +43,11 @@ "type" : "backend", "url" : "https:\/\/hermes.kang-kang.com\/feishu\/events" }, + { + "label" : "feishu-events-cli_a97764e101b95be9", + "type" : "backend", + "url" : "https:\/\/hermes.kang-kang.com\/feishu\/events\/cli_a97764e101b95be9" + }, { "label" : "feishu-notify", "type" : "backend", diff --git a/README.md b/README.md index 8ef9e5b..ef8f145 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ `server/feishu_bridge.py` 提供飞书双向桥接: - `POST /feishu/events`: 飞书事件回调 → Hermes → 飞书回复 +- `POST /feishu/events/{app_id}`: 多个飞书机器人共用桥服务时按路径区分 - `POST /feishu/notify`: Hermes / 内部系统主动推送到飞书 线上已部署为 systemd `hermes-feishu-bridge.service`,nginx 已公开 `/feishu/` 反代。凭证通过部署环境变量配置,详见 `server/feishu-bridge.env.example`;不要把 App Secret、Hermes API key 或通知 token 写入仓库。 diff --git a/RULES.md b/RULES.md index 0d49e53..803d6b6 100644 --- a/RULES.md +++ b/RULES.md @@ -10,6 +10,7 @@ - API / 后端:同域 `/api/v1` 转发到 LXC `hermes-personal` 内的 `hermes-agent:8642` - 飞书桥接:已部署 systemd `hermes-feishu-bridge.service`,宿主 `127.0.0.1:8787` - 飞书事件回调:https://hermes.kang-kang.com/feishu/events +- 飞书事件回调(`cli_a97764e101b95be9`):https://hermes.kang-kang.com/feishu/events/cli_a97764e101b95be9 - 飞书主动通知:https://hermes.kang-kang.com/feishu/notify - 文档 / 解析:https://styles.kang-kang.com - 管理后台:待定 @@ -32,8 +33,13 @@ - 飞书桥接服务: - `FEISHU_APP_ID` - `FEISHU_APP_SECRET`(敏感,不入库) + - `FEISHU_APP_ID_2` + - `FEISHU_APP_SECRET_2`(敏感,不入库) - `FEISHU_VERIFICATION_TOKEN`(可选,建议配置) + - `FEISHU_VERIFICATION_TOKEN_2`(可选,建议配置) - `FEISHU_DEFAULT_RECEIVE_ID` / `FEISHU_DEFAULT_RECEIVE_ID_TYPE`(主动通知默认目标) + - `FEISHU_DEFAULT_RECEIVE_ID_2` / `FEISHU_DEFAULT_RECEIVE_ID_TYPE_2`(第二应用主动通知默认目标) + - `FEISHU_DEFAULT_APP_ID` - `FEISHU_NOTIFY_TOKEN`(主动通知内部鉴权,敏感,不入库) - `HERMES_API_BASE` - `HERMES_API_KEY`(敏感,不入库) diff --git a/server/README.md b/server/README.md index 0a2c2ca..d669969 100644 --- a/server/README.md +++ b/server/README.md @@ -3,7 +3,9 @@ 双向桥接服务: - `POST /feishu/events`:飞书事件回调。收到文本消息后后台调用 Hermes,再发回飞书。 +- `POST /feishu/events/{app_id}`:多个飞书应用共用桥服务时,按路径指定应用。 - `POST /feishu/notify`:Hermes 或内部系统主动通知飞书。必须带 `Authorization: Bearer $FEISHU_NOTIFY_TOKEN` 或 `X-Hermes-Feishu-Token`。 +- `POST /feishu/notify/{app_id}`:主动通知时指定发消息的飞书应用,也可在 JSON 里传 `app_id`。 - `GET /health`:健康检查。 ## 凭证 @@ -13,7 +15,7 @@ ## 飞书后台配置 1. 给自建应用开通消息相关权限,例如接收消息事件、获取与发送单聊/群组消息。 -2. 事件订阅里添加请求地址:`https://hermes.kang-kang.com/feishu/events`。 +2. 事件订阅里添加请求地址:默认应用用 `https://hermes.kang-kang.com/feishu/events`;其它应用用 `https://hermes.kang-kang.com/feishu/events/{app_id}`。 3. 如果启用事件加密,需要先给本服务补充解密支持;当前版本按明文事件回调处理。 4. 建议配置 `FEISHU_VERIFICATION_TOKEN`,并保持和飞书后台一致。 @@ -24,4 +26,9 @@ 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":"任务完成"}' + +curl -X POST https://hermes.kang-kang.com/feishu/notify/cli_xxx \ + -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 index f4f1f60..4e61afc 100644 --- a/server/feishu-bridge.env.example +++ b/server/feishu-bridge.env.example @@ -3,8 +3,15 @@ FEISHU_APP_SECRET=replace-with-secret FEISHU_VERIFICATION_TOKEN= FEISHU_DEFAULT_RECEIVE_ID= FEISHU_DEFAULT_RECEIVE_ID_TYPE=chat_id +FEISHU_APP_ID_2=cli_yyyyyyyyyyyyyyyy +FEISHU_APP_SECRET_2=replace-with-second-secret +FEISHU_VERIFICATION_TOKEN_2= +FEISHU_DEFAULT_RECEIVE_ID_2= +FEISHU_DEFAULT_RECEIVE_ID_TYPE_2=chat_id +FEISHU_DEFAULT_APP_ID=cli_xxxxxxxxxxxxxxxx FEISHU_NOTIFY_TOKEN=replace-with-random-internal-token FEISHU_ALLOWED_CHAT_IDS= +FEISHU_ALLOWED_CHAT_IDS_2= FEISHU_REPLY_IN_THREAD=false HERMES_API_BASE=http://127.0.0.1:8642/v1 diff --git a/server/feishu_bridge.py b/server/feishu_bridge.py index afd294b..661f5cb 100644 --- a/server/feishu_bridge.py +++ b/server/feishu_bridge.py @@ -15,6 +15,7 @@ import threading import time import traceback import urllib.error +import urllib.parse import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any @@ -27,19 +28,42 @@ def _env(name: str, default: str = "") -> str: return os.environ.get(name, default).strip() +def _bool_env(name: str, default: str = "false") -> bool: + return _env(name, default).lower() in {"1", "true", "yes"} + + +def _csv_set(value: str) -> set[str]: + return {item.strip() for item in value.split(",") if item.strip()} + + +def _load_feishu_apps() -> dict[str, dict[str, Any]]: + apps: dict[str, dict[str, Any]] = {} + for suffix in ["", *[f"_{idx}" for idx in range(2, 10)]]: + app_id = _env(f"FEISHU_APP_ID{suffix}") + app_secret = _env(f"FEISHU_APP_SECRET{suffix}") + if not app_id and not app_secret: + continue + apps[app_id] = { + "app_id": app_id, + "app_secret": app_secret, + "verification_token": _env(f"FEISHU_VERIFICATION_TOKEN{suffix}"), + "default_receive_id": _env(f"FEISHU_DEFAULT_RECEIVE_ID{suffix}"), + "default_receive_id_type": _env( + f"FEISHU_DEFAULT_RECEIVE_ID_TYPE{suffix}", + "chat_id", + ), + "allowed_chat_ids": _csv_set(_env(f"FEISHU_ALLOWED_CHAT_IDS{suffix}")), + } + return apps + + 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") + feishu_apps = _load_feishu_apps() + default_feishu_app_id = _env("FEISHU_DEFAULT_APP_ID", feishu_app_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"} + reply_in_thread = _bool_env("FEISHU_REPLY_IN_THREAD") hermes_api_base = _env("HERMES_API_BASE", "http://127.0.0.1:8642/v1").rstrip("/") hermes_api_key = _env("HERMES_API_KEY") @@ -55,20 +79,23 @@ class Config: class TokenCache: def __init__(self) -> None: self._lock = threading.Lock() - self._token = "" - self._expires_at = 0.0 + self._tokens: dict[str, tuple[str, float]] = {} - def get(self) -> str: + def get(self, app_id: str) -> str: with self._lock: - if self._token and time.time() < self._expires_at - 300: - return self._token + token, expires_at = self._tokens.get(app_id, ("", 0.0)) + if token and time.time() < expires_at - 300: + return token - if not Config.feishu_app_id or not Config.feishu_app_secret: - raise RuntimeError("FEISHU_APP_ID and FEISHU_APP_SECRET are required") + app = Config.feishu_apps.get(app_id) + if not app: + raise RuntimeError(f"unknown Feishu app_id: {app_id}") + if not app.get("app_id") or not app.get("app_secret"): + raise RuntimeError(f"FEISHU_APP_ID and FEISHU_APP_SECRET are required for {app_id}") payload = { - "app_id": Config.feishu_app_id, - "app_secret": Config.feishu_app_secret, + "app_id": app["app_id"], + "app_secret": app["app_secret"], } data = http_json( "POST", @@ -78,9 +105,10 @@ class TokenCache: 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 = str(data["tenant_access_token"]) + expires_at = time.time() + int(data.get("expire", 7200)) + self._tokens[app_id] = (token, expires_at) + return token token_cache = TokenCache() @@ -131,8 +159,25 @@ def json_text(value: Any) -> str: return "" -def verify_callback_token(body: dict[str, Any]) -> bool: - expected = Config.feishu_verification_token +def resolve_app_id(path: str, body: dict[str, Any] | None = None) -> str: + route_app_id = "" + for prefix in ("/feishu/events/", "/feishu/notify/"): + if path.startswith(prefix): + route_app_id = urllib.parse.unquote(path[len(prefix) :].strip("/")) + break + if route_app_id: + return route_app_id + + body = body or {} + body_app_id = str(body.get("app_id") or body.get("header", {}).get("app_id") or "").strip() + if body_app_id in Config.feishu_apps: + return body_app_id + return Config.default_feishu_app_id + + +def verify_callback_token(body: dict[str, Any], app_id: str) -> bool: + app = Config.feishu_apps.get(app_id, {}) + expected = app.get("verification_token", "") if not expected: return True token = body.get("token") or body.get("header", {}).get("token") @@ -153,7 +198,11 @@ def remember_event(event_id: str) -> bool: return True -def handle_feishu_event(body: dict[str, Any]) -> tuple[int, dict[str, Any]]: +def handle_feishu_event(path: str, body: dict[str, Any]) -> tuple[int, dict[str, Any]]: + app_id = resolve_app_id(path, body) + if app_id not in Config.feishu_apps: + return 404, {"code": 404, "msg": f"unknown Feishu app_id: {app_id}"} + if "encrypt" in body: return 400, { "code": 400, @@ -161,26 +210,27 @@ def handle_feishu_event(body: dict[str, Any]) -> tuple[int, dict[str, Any]]: } if body.get("type") == "url_verification": - if not verify_callback_token(body): + if not verify_callback_token(body, app_id): return 403, {"code": 403, "msg": "invalid verification token"} return 200, {"challenge": body.get("challenge", "")} - if not verify_callback_token(body): + if not verify_callback_token(body, app_id): 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): + if not remember_event(f"{app_id}:{event_id}"): return 200, {"code": 0, "msg": "duplicate"} - threading.Thread(target=process_message_event, args=(body,), daemon=True).start() + threading.Thread(target=process_message_event, args=(app_id, body), daemon=True).start() return 200, {"code": 0, "msg": "ok"} -def process_message_event(body: dict[str, Any]) -> None: +def process_message_event(app_id: str, body: dict[str, Any]) -> None: try: + app = Config.feishu_apps[app_id] event = body.get("event", {}) sender = event.get("sender", {}) if sender.get("sender_type") == "app": @@ -190,21 +240,22 @@ def process_message_event(body: dict[str, Any]) -> None: 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) + allowed_chat_ids = app.get("allowed_chat_ids", set()) + if allowed_chat_ids and chat_id not in allowed_chat_ids: + logging.info("ignored message from disallowed app_id=%s chat_id=%s", app_id, chat_id) return if message_type != "text": - send_feishu_text(chat_id, "我现在只支持处理文本消息。", message_id=message_id) + send_feishu_text(app_id, 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) + send_feishu_text(app_id, chat_id, "我没有读到文本内容。", message_id=message_id) return answer = ask_hermes(text, event) - send_feishu_text(chat_id, answer, message_id=message_id) + send_feishu_text(app_id, chat_id, answer, message_id=message_id) except Exception: logging.error("failed to process Feishu event:\n%s", traceback.format_exc()) @@ -242,6 +293,7 @@ def ask_hermes(text: str, event: dict[str, Any]) -> str: def send_feishu_text( + app_id: str, receive_id: str, text: str, receive_id_type: str = "chat_id", @@ -250,7 +302,7 @@ def send_feishu_text( if not receive_id: raise RuntimeError("receive_id is required") - token = token_cache.get() + token = token_cache.get(app_id) chunks = split_text(text) result: dict[str, Any] = {} for chunk in chunks: @@ -288,7 +340,7 @@ def split_text(text: str, limit: int = 3800) -> list[str]: return chunks -def handle_notify(headers: dict[str, str], body: dict[str, Any]) -> tuple[int, dict[str, Any]]: +def handle_notify(path: str, 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"} @@ -300,18 +352,23 @@ def handle_notify(headers: dict[str, str], body: dict[str, Any]) -> tuple[int, d if token != expected: return 401, {"code": 401, "msg": "unauthorized"} + app_id = str(body.get("app_id") or resolve_app_id(path, body)).strip() + app = Config.feishu_apps.get(app_id) + if not app: + return 404, {"code": 404, "msg": f"unknown Feishu app_id: {app_id}"} + 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 = str(body.get("receive_id") or app.get("default_receive_id", "")).strip() receive_id_type = str( - body.get("receive_id_type") or Config.feishu_default_receive_id_type + body.get("receive_id_type") or app.get("default_receive_id_type", "chat_id") ).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} + result = send_feishu_text(app_id, receive_id, text, receive_id_type=receive_id_type) + return 200, {"code": 0, "msg": "ok", "app_id": app_id, "feishu": result} class Handler(BaseHTTPRequestHandler): @@ -327,10 +384,10 @@ class Handler(BaseHTTPRequestHandler): 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) + if self.path == "/feishu/events" or self.path.startswith("/feishu/events/"): + status, payload = handle_feishu_event(self.path, body) + elif self.path == "/feishu/notify" or self.path.startswith("/feishu/notify/"): + status, payload = handle_notify(self.path, headers, body) else: status, payload = 404, {"code": 404, "msg": "not found"} except Exception as exc: @@ -365,16 +422,15 @@ def main() -> None: 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 - ] + missing = [] + if not Config.feishu_apps: + missing.append("FEISHU_APP_ID and FEISHU_APP_SECRET") + for app_id, app in Config.feishu_apps.items(): + if not app.get("app_secret"): + missing.append(f"FEISHU_APP_SECRET for {app_id}") if missing: logging.warning("missing required env: %s", ", ".join(missing)) + logging.info("configured Feishu apps: %s", ", ".join(Config.feishu_apps) or "(none)") httpd = ThreadingHTTPServer(("0.0.0.0", Config.port), Handler) logging.info("Feishu bridge listening on :%s", Config.port) httpd.serve_forever()