auto-save 2026-05-11 14:39 (~6)
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
9
RULES.md
9
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/` 单独放行并反代到飞书桥接服务
|
||||
|
||||
@@ -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 "{}"
|
||||
|
||||
27
src/app.js
27
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() {
|
||||
<div class="feishu-app-title">${appId}</div>
|
||||
<div class="feishu-app-meta">${isDefault ? "默认应用" : "独立应用"} · ${tokenCount} 个校验 Token</div>
|
||||
</div>
|
||||
<span class="feishu-status">已接入</span>
|
||||
<div class="feishu-app-actions">
|
||||
<span class="feishu-status">已接入</span>
|
||||
<button class="icon-btn-mini danger" onclick="deleteFeishuApp('${encodedAppId}')" title="删除机器人">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v5"/><path d="M14 11v5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="feishu-callback">
|
||||
<span>${callbackUrl}</span>
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user