diff --git a/.memory/worklog.json b/.memory/worklog.json index 97ebcf2..4bc43fa 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,17 +1,5 @@ { "entries": [ - { - "files_changed": 1, - "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:auto-save 2026-05-10 06:35 (~1)", - "ts": "2026-05-09T22:35:54Z", - "type": "session-heartbeat" - }, - { - "files_changed": 1, - "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:auto-save 2026-05-10 06:35 (~1)", - "ts": "2026-05-09T22:38:29Z", - "type": "session-heartbeat" - }, { "files_changed": 1, "hash": "6aaa908", @@ -3255,6 +3243,19 @@ "message": "auto-save 2026-05-11 14:28 (~1)", "hash": "7ef98a9", "files_changed": 1 + }, + { + "ts": "2026-05-11T14:33:56+08:00", + "type": "commit", + "message": "auto-save 2026-05-11 14:33 (~1)", + "hash": "93118d4", + "files_changed": 1 + }, + { + "ts": "2026-05-11T06:36:27Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:auto-save 2026-05-11 14:33 (~1)", + "files_changed": 1 } ] } diff --git a/RULES.md b/RULES.md index 89bdb03..e63161c 100644 --- a/RULES.md +++ b/RULES.md @@ -13,12 +13,13 @@ - 飞书事件回调(`cli_a974771369bb1bc3`):https://hermes.kang-kang.com/feishu/events/cli_a974771369bb1bc3 - 飞书事件回调(`cli_a97764e101b95be9`):https://hermes.kang-kang.com/feishu/events/cli_a97764e101b95be9 - 飞书主动通知:https://hermes.kang-kang.com/feishu/notify -- 飞书机器人列表:https://hermes.kang-kang.com/feishu/apps(只读展示 App ID / 回调地址,不含 Secret / Token) -- 爱马仕前端「集成 → 飞书集成」可自助添加 / 更新飞书机器人;Secret / Token 只写入服务器 `/etc/hermes-feishu-bridge.env` +- 飞书机器人列表:https://hermes.kang-kang.com/feishu/apps(展示 App ID / 回调地址,不含 Secret / Token) +- 爱马仕前端「集成 → 飞书集成」可自助添加 / 更新 / 删除飞书机器人;Secret / Token 只写入服务器 `/etc/hermes-feishu-bridge.env` +- 飞书事件消息回复默认走 Feishu `im/v1/messages/{message_id}/reply`;主动通知仍走 `/feishu/notify` - 爱马仕前端「仪表盘」同步了上游 Hermes 的快捷入口板块,个人版展示主站、API、飞书机器人列表、文档/解析入口 - 爱马仕前端「仪表盘」活动热力图已重做为带摘要、月份标尺、紧凑格子和细分色阶的活动卡片 - 爱马仕前端「设置 → 连接」可自助维护 API 地址 / API Key 并测试连接;「对话 → 存周报」和「设置 → 周报记录」会在本地保存任务描述、上下文片段和最终周报 -- 当前前端静态壳缓存版本:`hermes-ui-v16` +- 当前前端静态壳缓存版本:`hermes-ui-v17` - 文档 / 解析:https://styles.kang-kang.com - 管理后台:待定 - 代码仓:https://git.kang-kang.com/kangwan/hermes-glass-ui-personal @@ -61,6 +62,6 @@ - 飞书 App Secret、Hermes API key、主动通知 token 只能放部署环境或忽略的 secrets 文件,不允许写入跟踪文件 - 线上飞书桥接环境:`/etc/hermes-feishu-bridge.env`,mode 600 - 飞书后台配置所需回调 URL、verification token、notify token 备份:`/root/hermes-feishu-bridge.tokens`,mode 600 -- 飞书自助添加接口 `POST /feishu/apps` 要求已登录爱马仕 cookie 且同源请求;未登录公网请求返回 401 +- 飞书自助配置接口 `POST /feishu/apps`、`DELETE /feishu/apps/{app_id}` 要求已登录爱马仕 cookie 且同源请求;未登录公网请求返回 401 - 当前飞书桥接版本按明文事件回调处理;如果飞书后台开启事件加密,需要先补充解密支持 - 主站有 cookie 门禁;nginx 已对 `/feishu/` 单独放行并反代到飞书桥接服务 diff --git a/server/feishu_bridge.py b/server/feishu_bridge.py index d4fce50..0d0c300 100644 --- a/server/feishu_bridge.py +++ b/server/feishu_bridge.py @@ -170,8 +170,48 @@ def write_env_updates(path: str, updates: dict[str, str]) -> None: os.replace(tmp, path) +def write_env_removals(path: str, remove_keys: set[str], updates: dict[str, str] | None = None) -> None: + updates = updates or {} + 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].strip() + if key in remove_keys: + continue + 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 suffix in app_suffixes(): + for prefix in ( + "FEISHU_APP_ID", + "FEISHU_APP_SECRET", + "FEISHU_VERIFICATION_TOKEN", + "FEISHU_DEFAULT_RECEIVE_ID", + "FEISHU_DEFAULT_RECEIVE_ID_TYPE", + "FEISHU_ALLOWED_CHAT_IDS", + ): + key = f"{prefix}{suffix}" + if key not in values: + os.environ.pop(key, None) + if "FEISHU_DEFAULT_APP_ID" not in values: + os.environ.pop("FEISHU_DEFAULT_APP_ID", None) for key, value in values.items(): os.environ[key] = value Config.feishu_app_id = _env("FEISHU_APP_ID") @@ -415,7 +455,7 @@ def send_feishu_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: + if message_id: url = f"{FEISHU_API_BASE}/im/v1/messages/{message_id}/reply" payload = {"msg_type": "text", "content": content} else: @@ -535,6 +575,48 @@ def upsert_feishu_app(body: dict[str, Any]) -> dict[str, Any]: } +def delete_feishu_app(app_id: str) -> dict[str, Any]: + app_id = app_id.strip() + if not APP_ID_RE.match(app_id): + raise ValueError("App ID must look like cli_xxx") + + env = parse_env_file(Config.env_file) + suffix = "" + for candidate in app_suffixes(): + if env.get(f"FEISHU_APP_ID{candidate}") == app_id: + suffix = candidate + break + else: + raise KeyError(app_id) + + remove_keys = { + f"FEISHU_APP_ID{suffix}", + f"FEISHU_APP_SECRET{suffix}", + f"FEISHU_VERIFICATION_TOKEN{suffix}", + f"FEISHU_DEFAULT_RECEIVE_ID{suffix}", + f"FEISHU_DEFAULT_RECEIVE_ID_TYPE{suffix}", + f"FEISHU_ALLOWED_CHAT_IDS{suffix}", + } + + updates: dict[str, str] = {} + current_default_app_id = env.get("FEISHU_DEFAULT_APP_ID") or env.get("FEISHU_APP_ID", "") + if current_default_app_id == app_id: + remaining_app_ids = [ + env.get(f"FEISHU_APP_ID{candidate}", "") + for candidate in app_suffixes() + if candidate != suffix and env.get(f"FEISHU_APP_ID{candidate}", "") + ] + remove_keys.add("FEISHU_DEFAULT_APP_ID") + if remaining_app_ids: + updates["FEISHU_DEFAULT_APP_ID"] = remaining_app_ids[0] + + write_env_removals(Config.env_file, remove_keys, updates) + sync_tokens_file() + reload_config_from_env_file() + token_cache.drop(app_id) + return {"app_id": 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: @@ -547,6 +629,23 @@ def handle_apps_admin(headers: dict[str, str], body: dict[str, Any]) -> tuple[in return 200, {"code": 0, "msg": "ok", "app": app} +def handle_apps_delete(path: str, headers: dict[str, str]) -> tuple[int, dict[str, Any]]: + ok, status, message = is_admin_request(headers) + if not ok: + return status, {"code": status, "msg": message} + + route = urllib.parse.urlparse(path).path + app_id = urllib.parse.unquote(route[len("/feishu/apps/") :].strip("/")) + try: + app = delete_feishu_app(app_id) + except ValueError as exc: + return 400, {"code": 400, "msg": str(exc)} + except KeyError: + return 404, {"code": 404, "msg": f"unknown Feishu app_id: {app_id}"} + logging.info("deleted 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: @@ -608,6 +707,18 @@ class Handler(BaseHTTPRequestHandler): status, payload = 500, {"code": 500, "msg": str(exc)} self.send_json(status, payload) + def do_DELETE(self) -> None: + try: + headers = {key.lower(): value for key, value in self.headers.items()} + if self.path.startswith("/feishu/apps/"): + status, payload = handle_apps_delete(self.path, headers) + 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 "{}" diff --git a/src/app.js b/src/app.js index cb54b23..b6cfb8a 100644 --- a/src/app.js +++ b/src/app.js @@ -390,6 +390,7 @@ async function refreshFeishuApps() { } box.innerHTML = apps.map(app => { const appId = escapeHTML(app.app_id || ""); + const encodedAppId = encodeURIComponent(app.app_id || ""); const callbackUrl = escapeHTML(app.callback_url || ""); const isDefault = app.app_id === data.default_app_id; const tokenCount = Number(app.verification_tokens_count || 0); @@ -400,7 +401,12 @@ async function refreshFeishuApps() {
${appId}
${isDefault ? "默认应用" : "独立应用"} · ${tokenCount} 个校验 Token
- 已接入 +
+ 已接入 + +
${callbackUrl} @@ -421,6 +427,25 @@ async function refreshFeishuApps() { } } +async function deleteFeishuApp(encodedAppId) { + const appId = decodeURIComponent(encodedAppId || ""); + if (!appId) return; + const ok = confirm(`删除飞书机器人 ${appId}?\n\n删除后这个 App ID 的回调地址会立刻失效,Secret / Token 也会从服务器环境文件移除。`); + if (!ok) return; + try { + const res = await fetch("/feishu/apps/" + encodeURIComponent(appId), { + method: "DELETE", + credentials: "same-origin", + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.code !== 0) throw new Error(data.msg || ("HTTP " + res.status)); + toast("飞书机器人已删除"); + await refreshFeishuApps(); + } catch (e) { + toast("删除失败: " + (e.message || e)); + } +} + async function saveFeishuApp(event) { event.preventDefault(); const appIdEl = document.getElementById("feishuAppId"); diff --git a/src/styles.css b/src/styles.css index 87d186b..438ce3a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2714,6 +2714,12 @@ a { color: var(--orange-3); text-decoration: none; } font-size: 11px; color: var(--text-dim2); } +.feishu-app-actions { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 8px; +} .feishu-status { flex: 0 0 auto; border: 1px solid rgba(80,220,140,0.32); @@ -2756,6 +2762,11 @@ a { color: var(--orange-3); text-decoration: none; } cursor: pointer; } .icon-btn-mini:hover { background: rgba(255,105,0,0.16); } +.icon-btn-mini.danger { + background: rgba(255,84,84,0.08); + color: #ff8585; +} +.icon-btn-mini.danger:hover { background: rgba(255,84,84,0.16); } .feishu-app-foot { display: flex; flex-wrap: wrap; diff --git a/src/sw.js b/src/sw.js index d0dac7b..9c4d179 100644 --- a/src/sw.js +++ b/src/sw.js @@ -1,6 +1,6 @@ // 爱马仕 Hermes · 轻量 Service Worker // 静态壳走 network-first(拿不到再回退缓存),API 直通 -const CACHE = "hermes-ui-v16"; +const CACHE = "hermes-ui-v17"; const ASSETS = [ "./", "./index.html",