auto-save 2026-05-09 16:18 (+1, ~5)
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,3 +5,5 @@ deploy/secrets/
|
|||||||
*.htpasswd*
|
*.htpasswd*
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|||||||
@@ -1,19 +1,5 @@
|
|||||||
{
|
{
|
||||||
"entries": [
|
"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,
|
"files_changed": 1,
|
||||||
"hash": "18087a5",
|
"hash": "18087a5",
|
||||||
@@ -3493,6 +3479,19 @@
|
|||||||
"type": "session-heartbeat",
|
"type": "session-heartbeat",
|
||||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 2 项未提交变更 · 最近提交:auto-save 2026-05-09 16:06 (~5)",
|
"message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 2 项未提交变更 · 最近提交:auto-save 2026-05-09 16:06 (~5)",
|
||||||
"files_changed": 2
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
{
|
{
|
||||||
"category" : "Hermes Glass UI 个人版(hermes.kang-kang.com, 2026-04-21)",
|
"category" : "Hermes Glass UI 个人版(hermes.kang-kang.com, 2026-04-21)",
|
||||||
"entry" : "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)",
|
"description" : "Fork from 20260414-hermes-glass-ui, 单用户个人 VPS 部署 (hermes.kang-kang.com)",
|
||||||
|
|||||||
@@ -10,6 +10,15 @@
|
|||||||
- **模型**: Google Gemini 3 Pro Preview (via OpenRouter)
|
- **模型**: Google Gemini 3 Pro Preview (via OpenRouter)
|
||||||
- **登录**: 单用户 `kang`
|
- **登录**: 单用户 `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) |
|
| 维度 | 公司版 (hermes.milejoy.com) | 个人版 (hermes.kang-kang.com) |
|
||||||
|
|||||||
15
RULES.md
15
RULES.md
@@ -8,6 +8,7 @@
|
|||||||
- 发布状态:已部署
|
- 发布状态:已部署
|
||||||
- 主站 / 前端:https://hermes.kang-kang.com
|
- 主站 / 前端:https://hermes.kang-kang.com
|
||||||
- API / 后端:同域 `/api/v1` 转发到 LXC `hermes-personal` 内的 `hermes-agent:8642`
|
- API / 后端:同域 `/api/v1` 转发到 LXC `hermes-personal` 内的 `hermes-agent:8642`
|
||||||
|
- 飞书桥接:源码在 `server/feishu_bridge.py`;线上计划开放 `/feishu/events`(飞书事件)和 `/feishu/notify`(主动通知)
|
||||||
- 文档 / 解析:https://styles.kang-kang.com
|
- 文档 / 解析:https://styles.kang-kang.com
|
||||||
- 管理后台:待定
|
- 管理后台:待定
|
||||||
- 代码仓:https://git.kang-kang.com/kangwan/hermes-glass-ui-personal
|
- 代码仓:https://git.kang-kang.com/kangwan/hermes-glass-ui-personal
|
||||||
@@ -26,7 +27,15 @@
|
|||||||
- 部署完成后,`RULES.md` 和 `.project.json` 必须同一次任务一起更新
|
- 部署完成后,`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 校验和事件投递
|
||||||
|
|||||||
27
server/README.md
Normal file
27
server/README.md
Normal file
@@ -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":"任务完成"}'
|
||||||
|
```
|
||||||
17
server/feishu-bridge.env.example
Normal file
17
server/feishu-bridge.env.example
Normal file
@@ -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
|
||||||
16
server/feishu-bridge.service.example
Normal file
16
server/feishu-bridge.service.example
Normal file
@@ -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
|
||||||
384
server/feishu_bridge.py
Normal file
384
server/feishu_bridge.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user