diff --git a/.memory/worklog.json b/.memory/worklog.json index d59852c..d942f50 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,26 +1,5 @@ { "entries": [ - { - "files_changed": 1, - "hash": "fb18cba", - "message": "auto-save 2026-05-07 16:24 (~1)", - "ts": "2026-05-07T16:24:27+08:00", - "type": "commit" - }, - { - "files_changed": 1, - "hash": "8820687", - "message": "auto-save 2026-05-07 16:29 (~1)", - "ts": "2026-05-07T16:29:58+08:00", - "type": "commit" - }, - { - "files_changed": 1, - "hash": "60c49c9", - "message": "auto-save 2026-05-07 16:35 (~1)", - "ts": "2026-05-07T16:35:31+08:00", - "type": "commit" - }, { "files_changed": 1, "hash": "f808cae", @@ -3469,6 +3448,25 @@ "message": "auto-save 2026-05-09 18:09 (~3)", "hash": "2828de0", "files_changed": 3 + }, + { + "ts": "2026-05-09T18:14:35+08:00", + "type": "commit", + "message": "auto-save 2026-05-09 18:14 (~1)", + "hash": "a64489f", + "files_changed": 1 + }, + { + "ts": "2026-05-09T10:15:53Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 2 项未提交变更 · 最近提交:auto-save 2026-05-09 18:14 (~1)", + "files_changed": 2 + }, + { + "ts": "2026-05-09T10:18:28Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 6 项未提交变更 · 最近提交:auto-save 2026-05-09 18:14 (~1)", + "files_changed": 6 } ] } diff --git a/server/feishu_bridge.py b/server/feishu_bridge.py index c2e129e..d4fce50 100644 --- a/server/feishu_bridge.py +++ b/server/feishu_bridge.py @@ -12,6 +12,7 @@ import json import logging import os import hashlib +import re import threading import time import traceback @@ -23,6 +24,7 @@ from typing import Any FEISHU_API_BASE = "https://open.feishu.cn/open-apis" +APP_ID_RE = re.compile(r"^cli_[A-Za-z0-9]+$") def _env(name: str, default: str = "") -> str: @@ -41,9 +43,13 @@ def _csv_list(value: str) -> list[str]: return [item.strip() for item in value.split(",") if item.strip()] +def app_suffixes() -> list[str]: + return ["", *[f"_{idx}" for idx in range(2, 21)]] + + 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)]]: + for suffix in app_suffixes(): app_id = _env(f"FEISHU_APP_ID{suffix}") app_secret = _env(f"FEISHU_APP_SECRET{suffix}") if not app_id and not app_secret: @@ -63,6 +69,8 @@ def _load_feishu_apps() -> dict[str, dict[str, Any]]: class Config: + env_file = _env("FEISHU_BRIDGE_ENV_FILE", "/etc/hermes-feishu-bridge.env") + tokens_file = _env("FEISHU_BRIDGE_TOKENS_FILE", "/root/hermes-feishu-bridge.tokens") feishu_app_id = _env("FEISHU_APP_ID") feishu_app_secret = _env("FEISHU_APP_SECRET") feishu_apps = _load_feishu_apps() @@ -115,12 +123,86 @@ class TokenCache: self._tokens[app_id] = (token, expires_at) return token + def drop(self, app_id: str) -> None: + with self._lock: + self._tokens.pop(app_id, None) + token_cache = TokenCache() seen_events: dict[str, float] = {} seen_lock = threading.Lock() +def parse_env_file(path: str) -> dict[str, str]: + values: dict[str, str] = {} + if not os.path.exists(path): + return values + with open(path, "r", encoding="utf-8") as fh: + for raw in fh: + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip() + return values + + +def write_env_updates(path: str, updates: dict[str, str]) -> None: + lines: list[str] = [] + seen: set[str] = set() + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as fh: + for raw in fh.read().splitlines(): + if "=" in raw and not raw.lstrip().startswith("#"): + key = raw.split("=", 1)[0] + if key in updates: + lines.append(f"{key}={updates[key]}") + seen.add(key) + continue + lines.append(raw) + for key, value in updates.items(): + if key not in seen: + lines.append(f"{key}={value}") + tmp = f"{path}.tmp-{os.getpid()}" + with open(tmp, "w", encoding="utf-8") as fh: + fh.write("\n".join(lines) + "\n") + os.chmod(tmp, 0o600) + os.replace(tmp, path) + + +def reload_config_from_env_file() -> None: + values = parse_env_file(Config.env_file) + for key, value in values.items(): + os.environ[key] = value + Config.feishu_app_id = _env("FEISHU_APP_ID") + Config.feishu_app_secret = _env("FEISHU_APP_SECRET") + Config.feishu_apps = _load_feishu_apps() + Config.default_feishu_app_id = _env("FEISHU_DEFAULT_APP_ID", Config.feishu_app_id) + + +def sync_tokens_file() -> None: + env = parse_env_file(Config.env_file) + lines: list[str] = [] + notify_token = env.get("FEISHU_NOTIFY_TOKEN") + for suffix in app_suffixes(): + app_id = env.get(f"FEISHU_APP_ID{suffix}", "") + if not app_id: + continue + label = suffix or "" + lines.append(f"FEISHU_CALLBACK_URL{label}=https://hermes.kang-kang.com/feishu/events/{app_id}") + lines.append(f"FEISHU_APP_ID{label}={app_id}") + token = env.get(f"FEISHU_VERIFICATION_TOKEN{suffix}", "") + if token: + lines.append(f"FEISHU_VERIFICATION_TOKEN{label}={token}") + if notify_token: + lines.append(f"FEISHU_NOTIFY_TOKEN={notify_token}") + tmp = f"{Config.tokens_file}.tmp-{os.getpid()}" + with open(tmp, "w", encoding="utf-8") as fh: + fh.write("\n".join(lines) + "\n") + os.chmod(tmp, 0o600) + os.replace(tmp, Config.tokens_file) + + def http_json( method: str, url: str, @@ -385,6 +467,86 @@ def handle_apps() -> tuple[int, dict[str, Any]]: } +def is_admin_request(headers: dict[str, str]) -> tuple[bool, int, str]: + cookie = headers.get("cookie", "") + if "hermes_auth=ok" not in cookie: + return False, 401, "login required" + origin = headers.get("origin", "") + referer = headers.get("referer", "") + allowed = "https://hermes.kang-kang.com" + if origin and origin != allowed: + return False, 403, "invalid origin" + if not origin and referer and not referer.startswith(allowed + "/"): + return False, 403, "invalid referer" + return True, 200, "ok" + + +def validate_feishu_credentials(app_id: str, app_secret: str) -> None: + data = http_json( + "POST", + f"{FEISHU_API_BASE}/auth/v3/tenant_access_token/internal", + {"app_id": app_id, "app_secret": app_secret}, + timeout=15, + ) + if data.get("code") != 0: + raise ValueError(f"Feishu credential validation failed: {data.get('msg') or data}") + + +def find_app_suffix(env: dict[str, str], app_id: str) -> str: + for suffix in app_suffixes(): + if env.get(f"FEISHU_APP_ID{suffix}") == app_id: + return suffix + for suffix in app_suffixes(): + if not env.get(f"FEISHU_APP_ID{suffix}") and not env.get(f"FEISHU_APP_SECRET{suffix}"): + return suffix + raise RuntimeError("no free Feishu app slot; supported slots are FEISHU_APP_ID through FEISHU_APP_ID_20") + + +def upsert_feishu_app(body: dict[str, Any]) -> dict[str, Any]: + app_id = str(body.get("app_id", "")).strip() + app_secret = str(body.get("app_secret", "")).strip() + verification_token = str(body.get("verification_token", "")).strip() + if not APP_ID_RE.match(app_id): + raise ValueError("App ID must look like cli_xxx") + if len(app_secret) < 16: + raise ValueError("App Secret is too short") + if len(verification_token) < 16: + raise ValueError("Verification Token is too short") + + validate_feishu_credentials(app_id, app_secret) + + env = parse_env_file(Config.env_file) + suffix = find_app_suffix(env, app_id) + updates = { + f"FEISHU_APP_ID{suffix}": app_id, + f"FEISHU_APP_SECRET{suffix}": app_secret, + f"FEISHU_VERIFICATION_TOKEN{suffix}": verification_token, + } + if not env.get(f"FEISHU_DEFAULT_RECEIVE_ID_TYPE{suffix}"): + updates[f"FEISHU_DEFAULT_RECEIVE_ID_TYPE{suffix}"] = "chat_id" + write_env_updates(Config.env_file, updates) + sync_tokens_file() + reload_config_from_env_file() + token_cache.drop(app_id) + return { + "app_id": app_id, + "callback_url": f"https://hermes.kang-kang.com/feishu/events/{app_id}", + "slot": suffix or "_1", + } + + +def handle_apps_admin(headers: dict[str, str], body: dict[str, Any]) -> tuple[int, dict[str, Any]]: + ok, status, message = is_admin_request(headers) + if not ok: + return status, {"code": status, "msg": message} + try: + app = upsert_feishu_app(body) + except ValueError as exc: + return 400, {"code": 400, "msg": str(exc)} + logging.info("upserted Feishu app from Hermes settings app_id=%s", app["app_id"]) + return 200, {"code": 0, "msg": "ok", "app": app} + + 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: @@ -435,6 +597,8 @@ class Handler(BaseHTTPRequestHandler): headers = {key.lower(): value for key, value in self.headers.items()} if self.path == "/feishu/events" or self.path.startswith("/feishu/events/"): status, payload = handle_feishu_event(self.path, body) + elif self.path == "/feishu/apps": + status, payload = handle_apps_admin(headers, body) elif self.path == "/feishu/notify" or self.path.startswith("/feishu/notify/"): status, payload = handle_notify(self.path, headers, body) else: diff --git a/src/app.js b/src/app.js index e30aec9..77678a1 100644 --- a/src/app.js +++ b/src/app.js @@ -356,6 +356,7 @@ function switchTab(name) { // ---------- 飞书集成 ---------- let _feishuAppsLoading = false; +let _feishuLastApps = []; async function refreshFeishuApps() { const box = document.getElementById("feishuApps"); if (!box || _feishuAppsLoading) return; @@ -366,6 +367,7 @@ async function refreshFeishuApps() { if (!res.ok) throw new Error("HTTP " + res.status); const data = await res.json(); const apps = Array.isArray(data.apps) ? data.apps : []; + _feishuLastApps = apps; if (!apps.length) { box.innerHTML = '
还没有读取到飞书机器人配置。
'; return; @@ -403,6 +405,52 @@ async function refreshFeishuApps() { } } +async function saveFeishuApp(event) { + event.preventDefault(); + const appIdEl = document.getElementById("feishuAppId"); + const secretEl = document.getElementById("feishuAppSecret"); + const tokenEl = document.getElementById("feishuVerifyToken"); + const app_id = appIdEl?.value.trim() || ""; + const app_secret = secretEl?.value.trim() || ""; + const verification_token = tokenEl?.value.trim() || ""; + if (!/^cli_[A-Za-z0-9]+$/.test(app_id)) { + toast("App ID 格式不对"); + return; + } + if (app_secret.length < 16 || verification_token.length < 16) { + toast("Secret / Token 太短"); + return; + } + const submit = event.target.querySelector("button[type='submit']"); + const oldText = submit?.textContent; + if (submit) { + submit.disabled = true; + submit.textContent = "正在保存..."; + } + try { + const res = await fetch("/feishu/apps", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ app_id, app_secret, verification_token }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.code !== 0) throw new Error(data.msg || ("HTTP " + res.status)); + secretEl.value = ""; + tokenEl.value = ""; + toast("飞书机器人已保存"); + await refreshFeishuApps(); + if (data.app?.callback_url) copyText(data.app.callback_url); + } catch (e) { + toast("保存失败: " + (e.message || e)); + } finally { + if (submit) { + submit.disabled = false; + submit.innerHTML = '添加 / 更新机器人'; + } + } +} + // ---------- 带认证续期的 fetch ---------- // nginx 的 hermes_auth cookie 默认 24h 过期;过期后 /api/v1/* 全部 401。 // 这里在 401 时尝试一次静默续期(浏览器若缓存了 Basic Auth 会自动带上), diff --git a/src/index.html b/src/index.html index 7b89d4a..2a945c9 100644 --- a/src/index.html +++ b/src/index.html @@ -1110,6 +1110,27 @@ git push # Gitea kangwan/hermes-glass-ui-personal
打开设置页后自动读取飞书桥接服务。
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
保存前会校验 App ID / Secret;成功后复制回调地址到飞书事件配置。
+
+
diff --git a/src/styles.css b/src/styles.css index c77b13f..d5dd2bc 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2394,6 +2394,23 @@ a { color: var(--orange-3); text-decoration: none; } border-radius: 999px; background: rgba(255,255,255,0.035); } +.feishu-form { + border-top: 1px solid var(--line); + padding-top: 16px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} +.feishu-form-actions { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} +.feishu-form-actions .glass-btn-sm { + margin: 0; +} .danger-btn { border-color: rgba(255,93,122,0.3) !important; color: var(--err) !important; diff --git a/src/sw.js b/src/sw.js index c65b786..ce061bb 100644 --- a/src/sw.js +++ b/src/sw.js @@ -1,6 +1,6 @@ // 爱马仕 Hermes · 轻量 Service Worker // 静态壳走 network-first(拿不到再回退缓存),API 直通 -const CACHE = "hermes-ui-v10"; +const CACHE = "hermes-ui-v11"; const ASSETS = [ "./", "./index.html",