auto-save 2026-05-09 16:40 (~7)
This commit is contained in:
@@ -1,26 +1,5 @@
|
|||||||
{
|
{
|
||||||
"entries": [
|
"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,
|
"files_changed": 1,
|
||||||
"hash": "8eed9ec",
|
"hash": "8eed9ec",
|
||||||
@@ -3489,6 +3468,25 @@
|
|||||||
"message": "auto-save 2026-05-09 16:29 (~1)",
|
"message": "auto-save 2026-05-09 16:29 (~1)",
|
||||||
"hash": "bdb8fe7",
|
"hash": "bdb8fe7",
|
||||||
"files_changed": 1
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
{
|
{
|
||||||
"category" : "Feishu 自建应用",
|
"category" : "Feishu 自建应用",
|
||||||
"entry" : "Hermes Feishu App cli_a974771369bb1bc3"
|
"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)",
|
"description" : "Fork from 20260414-hermes-glass-ui, 单用户个人 VPS 部署 (hermes.kang-kang.com)",
|
||||||
@@ -39,6 +43,11 @@
|
|||||||
"type" : "backend",
|
"type" : "backend",
|
||||||
"url" : "https:\/\/hermes.kang-kang.com\/feishu\/events"
|
"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",
|
"label" : "feishu-notify",
|
||||||
"type" : "backend",
|
"type" : "backend",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
`server/feishu_bridge.py` 提供飞书双向桥接:
|
`server/feishu_bridge.py` 提供飞书双向桥接:
|
||||||
|
|
||||||
- `POST /feishu/events`: 飞书事件回调 → Hermes → 飞书回复
|
- `POST /feishu/events`: 飞书事件回调 → Hermes → 飞书回复
|
||||||
|
- `POST /feishu/events/{app_id}`: 多个飞书机器人共用桥服务时按路径区分
|
||||||
- `POST /feishu/notify`: Hermes / 内部系统主动推送到飞书
|
- `POST /feishu/notify`: Hermes / 内部系统主动推送到飞书
|
||||||
|
|
||||||
线上已部署为 systemd `hermes-feishu-bridge.service`,nginx 已公开 `/feishu/` 反代。凭证通过部署环境变量配置,详见 `server/feishu-bridge.env.example`;不要把 App Secret、Hermes API key 或通知 token 写入仓库。
|
线上已部署为 systemd `hermes-feishu-bridge.service`,nginx 已公开 `/feishu/` 反代。凭证通过部署环境变量配置,详见 `server/feishu-bridge.env.example`;不要把 App Secret、Hermes API key 或通知 token 写入仓库。
|
||||||
|
|||||||
6
RULES.md
6
RULES.md
@@ -10,6 +10,7 @@
|
|||||||
- API / 后端:同域 `/api/v1` 转发到 LXC `hermes-personal` 内的 `hermes-agent:8642`
|
- API / 后端:同域 `/api/v1` 转发到 LXC `hermes-personal` 内的 `hermes-agent:8642`
|
||||||
- 飞书桥接:已部署 systemd `hermes-feishu-bridge.service`,宿主 `127.0.0.1:8787`
|
- 飞书桥接:已部署 systemd `hermes-feishu-bridge.service`,宿主 `127.0.0.1:8787`
|
||||||
- 飞书事件回调:https://hermes.kang-kang.com/feishu/events
|
- 飞书事件回调: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://hermes.kang-kang.com/feishu/notify
|
||||||
- 文档 / 解析:https://styles.kang-kang.com
|
- 文档 / 解析:https://styles.kang-kang.com
|
||||||
- 管理后台:待定
|
- 管理后台:待定
|
||||||
@@ -32,8 +33,13 @@
|
|||||||
- 飞书桥接服务:
|
- 飞书桥接服务:
|
||||||
- `FEISHU_APP_ID`
|
- `FEISHU_APP_ID`
|
||||||
- `FEISHU_APP_SECRET`(敏感,不入库)
|
- `FEISHU_APP_SECRET`(敏感,不入库)
|
||||||
|
- `FEISHU_APP_ID_2`
|
||||||
|
- `FEISHU_APP_SECRET_2`(敏感,不入库)
|
||||||
- `FEISHU_VERIFICATION_TOKEN`(可选,建议配置)
|
- `FEISHU_VERIFICATION_TOKEN`(可选,建议配置)
|
||||||
|
- `FEISHU_VERIFICATION_TOKEN_2`(可选,建议配置)
|
||||||
- `FEISHU_DEFAULT_RECEIVE_ID` / `FEISHU_DEFAULT_RECEIVE_ID_TYPE`(主动通知默认目标)
|
- `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`(主动通知内部鉴权,敏感,不入库)
|
- `FEISHU_NOTIFY_TOKEN`(主动通知内部鉴权,敏感,不入库)
|
||||||
- `HERMES_API_BASE`
|
- `HERMES_API_BASE`
|
||||||
- `HERMES_API_KEY`(敏感,不入库)
|
- `HERMES_API_KEY`(敏感,不入库)
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
双向桥接服务:
|
双向桥接服务:
|
||||||
|
|
||||||
- `POST /feishu/events`:飞书事件回调。收到文本消息后后台调用 Hermes,再发回飞书。
|
- `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`:Hermes 或内部系统主动通知飞书。必须带 `Authorization: Bearer $FEISHU_NOTIFY_TOKEN` 或 `X-Hermes-Feishu-Token`。
|
||||||
|
- `POST /feishu/notify/{app_id}`:主动通知时指定发消息的飞书应用,也可在 JSON 里传 `app_id`。
|
||||||
- `GET /health`:健康检查。
|
- `GET /health`:健康检查。
|
||||||
|
|
||||||
## 凭证
|
## 凭证
|
||||||
@@ -13,7 +15,7 @@
|
|||||||
## 飞书后台配置
|
## 飞书后台配置
|
||||||
|
|
||||||
1. 给自建应用开通消息相关权限,例如接收消息事件、获取与发送单聊/群组消息。
|
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. 如果启用事件加密,需要先给本服务补充解密支持;当前版本按明文事件回调处理。
|
3. 如果启用事件加密,需要先给本服务补充解密支持;当前版本按明文事件回调处理。
|
||||||
4. 建议配置 `FEISHU_VERIFICATION_TOKEN`,并保持和飞书后台一致。
|
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 "Authorization: Bearer $FEISHU_NOTIFY_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"receive_id_type":"chat_id","receive_id":"oc_xxx","text":"任务完成"}'
|
-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":"任务完成"}'
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -3,8 +3,15 @@ FEISHU_APP_SECRET=replace-with-secret
|
|||||||
FEISHU_VERIFICATION_TOKEN=
|
FEISHU_VERIFICATION_TOKEN=
|
||||||
FEISHU_DEFAULT_RECEIVE_ID=
|
FEISHU_DEFAULT_RECEIVE_ID=
|
||||||
FEISHU_DEFAULT_RECEIVE_ID_TYPE=chat_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_NOTIFY_TOKEN=replace-with-random-internal-token
|
||||||
FEISHU_ALLOWED_CHAT_IDS=
|
FEISHU_ALLOWED_CHAT_IDS=
|
||||||
|
FEISHU_ALLOWED_CHAT_IDS_2=
|
||||||
FEISHU_REPLY_IN_THREAD=false
|
FEISHU_REPLY_IN_THREAD=false
|
||||||
|
|
||||||
HERMES_API_BASE=http://127.0.0.1:8642/v1
|
HERMES_API_BASE=http://127.0.0.1:8642/v1
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -27,19 +28,42 @@ def _env(name: str, default: str = "") -> str:
|
|||||||
return os.environ.get(name, default).strip()
|
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:
|
class Config:
|
||||||
feishu_app_id = _env("FEISHU_APP_ID")
|
feishu_app_id = _env("FEISHU_APP_ID")
|
||||||
feishu_app_secret = _env("FEISHU_APP_SECRET")
|
feishu_app_secret = _env("FEISHU_APP_SECRET")
|
||||||
feishu_verification_token = _env("FEISHU_VERIFICATION_TOKEN")
|
feishu_apps = _load_feishu_apps()
|
||||||
feishu_default_receive_id = _env("FEISHU_DEFAULT_RECEIVE_ID")
|
default_feishu_app_id = _env("FEISHU_DEFAULT_APP_ID", feishu_app_id)
|
||||||
feishu_default_receive_id_type = _env("FEISHU_DEFAULT_RECEIVE_ID_TYPE", "chat_id")
|
|
||||||
notify_token = _env("FEISHU_NOTIFY_TOKEN")
|
notify_token = _env("FEISHU_NOTIFY_TOKEN")
|
||||||
allowed_chat_ids = {
|
reply_in_thread = _bool_env("FEISHU_REPLY_IN_THREAD")
|
||||||
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_base = _env("HERMES_API_BASE", "http://127.0.0.1:8642/v1").rstrip("/")
|
||||||
hermes_api_key = _env("HERMES_API_KEY")
|
hermes_api_key = _env("HERMES_API_KEY")
|
||||||
@@ -55,20 +79,23 @@ class Config:
|
|||||||
class TokenCache:
|
class TokenCache:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._token = ""
|
self._tokens: dict[str, tuple[str, float]] = {}
|
||||||
self._expires_at = 0.0
|
|
||||||
|
|
||||||
def get(self) -> str:
|
def get(self, app_id: str) -> str:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._token and time.time() < self._expires_at - 300:
|
token, expires_at = self._tokens.get(app_id, ("", 0.0))
|
||||||
return self._token
|
if token and time.time() < expires_at - 300:
|
||||||
|
return token
|
||||||
|
|
||||||
if not Config.feishu_app_id or not Config.feishu_app_secret:
|
app = Config.feishu_apps.get(app_id)
|
||||||
raise RuntimeError("FEISHU_APP_ID and FEISHU_APP_SECRET are required")
|
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 = {
|
payload = {
|
||||||
"app_id": Config.feishu_app_id,
|
"app_id": app["app_id"],
|
||||||
"app_secret": Config.feishu_app_secret,
|
"app_secret": app["app_secret"],
|
||||||
}
|
}
|
||||||
data = http_json(
|
data = http_json(
|
||||||
"POST",
|
"POST",
|
||||||
@@ -78,9 +105,10 @@ class TokenCache:
|
|||||||
if data.get("code") != 0:
|
if data.get("code") != 0:
|
||||||
raise RuntimeError(f"Feishu token error: {data}")
|
raise RuntimeError(f"Feishu token error: {data}")
|
||||||
|
|
||||||
self._token = str(data["tenant_access_token"])
|
token = str(data["tenant_access_token"])
|
||||||
self._expires_at = time.time() + int(data.get("expire", 7200))
|
expires_at = time.time() + int(data.get("expire", 7200))
|
||||||
return self._token
|
self._tokens[app_id] = (token, expires_at)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
token_cache = TokenCache()
|
token_cache = TokenCache()
|
||||||
@@ -131,8 +159,25 @@ def json_text(value: Any) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def verify_callback_token(body: dict[str, Any]) -> bool:
|
def resolve_app_id(path: str, body: dict[str, Any] | None = None) -> str:
|
||||||
expected = Config.feishu_verification_token
|
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:
|
if not expected:
|
||||||
return True
|
return True
|
||||||
token = body.get("token") or body.get("header", {}).get("token")
|
token = body.get("token") or body.get("header", {}).get("token")
|
||||||
@@ -153,7 +198,11 @@ def remember_event(event_id: str) -> bool:
|
|||||||
return True
|
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:
|
if "encrypt" in body:
|
||||||
return 400, {
|
return 400, {
|
||||||
"code": 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 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 403, {"code": 403, "msg": "invalid verification token"}
|
||||||
return 200, {"challenge": body.get("challenge", "")}
|
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"}
|
return 403, {"code": 403, "msg": "invalid verification token"}
|
||||||
|
|
||||||
event_type = body.get("header", {}).get("event_type") or body.get("event", {}).get("type")
|
event_type = body.get("header", {}).get("event_type") or body.get("event", {}).get("type")
|
||||||
event_id = body.get("header", {}).get("event_id", "")
|
event_id = body.get("header", {}).get("event_id", "")
|
||||||
if event_type != "im.message.receive_v1":
|
if event_type != "im.message.receive_v1":
|
||||||
return 200, {"code": 0, "msg": "ignored"}
|
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"}
|
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"}
|
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:
|
try:
|
||||||
|
app = Config.feishu_apps[app_id]
|
||||||
event = body.get("event", {})
|
event = body.get("event", {})
|
||||||
sender = event.get("sender", {})
|
sender = event.get("sender", {})
|
||||||
if sender.get("sender_type") == "app":
|
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")
|
message_type = message.get("message_type")
|
||||||
chat_id = message.get("chat_id", "")
|
chat_id = message.get("chat_id", "")
|
||||||
message_id = message.get("message_id", "")
|
message_id = message.get("message_id", "")
|
||||||
if Config.allowed_chat_ids and chat_id not in Config.allowed_chat_ids:
|
allowed_chat_ids = app.get("allowed_chat_ids", set())
|
||||||
logging.info("ignored message from disallowed chat_id=%s", chat_id)
|
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
|
return
|
||||||
|
|
||||||
if message_type != "text":
|
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
|
return
|
||||||
|
|
||||||
text = json_text(message.get("content"))
|
text = json_text(message.get("content"))
|
||||||
if not text:
|
if not text:
|
||||||
send_feishu_text(chat_id, "我没有读到文本内容。", message_id=message_id)
|
send_feishu_text(app_id, chat_id, "我没有读到文本内容。", message_id=message_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
answer = ask_hermes(text, event)
|
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:
|
except Exception:
|
||||||
logging.error("failed to process Feishu event:\n%s", traceback.format_exc())
|
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(
|
def send_feishu_text(
|
||||||
|
app_id: str,
|
||||||
receive_id: str,
|
receive_id: str,
|
||||||
text: str,
|
text: str,
|
||||||
receive_id_type: str = "chat_id",
|
receive_id_type: str = "chat_id",
|
||||||
@@ -250,7 +302,7 @@ def send_feishu_text(
|
|||||||
if not receive_id:
|
if not receive_id:
|
||||||
raise RuntimeError("receive_id is required")
|
raise RuntimeError("receive_id is required")
|
||||||
|
|
||||||
token = token_cache.get()
|
token = token_cache.get(app_id)
|
||||||
chunks = split_text(text)
|
chunks = split_text(text)
|
||||||
result: dict[str, Any] = {}
|
result: dict[str, Any] = {}
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
@@ -288,7 +340,7 @@ def split_text(text: str, limit: int = 3800) -> list[str]:
|
|||||||
return chunks
|
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
|
expected = Config.notify_token
|
||||||
if not expected:
|
if not expected:
|
||||||
return 503, {"code": 503, "msg": "FEISHU_NOTIFY_TOKEN is not configured"}
|
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:
|
if token != expected:
|
||||||
return 401, {"code": 401, "msg": "unauthorized"}
|
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()
|
text = str(body.get("text", "")).strip()
|
||||||
if not text:
|
if not text:
|
||||||
return 400, {"code": 400, "msg": "text is required"}
|
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(
|
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()
|
).strip()
|
||||||
if not receive_id:
|
if not receive_id:
|
||||||
return 400, {"code": 400, "msg": "receive_id is required"}
|
return 400, {"code": 400, "msg": "receive_id is required"}
|
||||||
|
|
||||||
result = send_feishu_text(receive_id, text, receive_id_type=receive_id_type)
|
result = send_feishu_text(app_id, receive_id, text, receive_id_type=receive_id_type)
|
||||||
return 200, {"code": 0, "msg": "ok", "feishu": result}
|
return 200, {"code": 0, "msg": "ok", "app_id": app_id, "feishu": result}
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
@@ -327,10 +384,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
try:
|
try:
|
||||||
body = self.read_json()
|
body = self.read_json()
|
||||||
headers = {key.lower(): value for key, value in self.headers.items()}
|
headers = {key.lower(): value for key, value in self.headers.items()}
|
||||||
if self.path == "/feishu/events":
|
if self.path == "/feishu/events" or self.path.startswith("/feishu/events/"):
|
||||||
status, payload = handle_feishu_event(body)
|
status, payload = handle_feishu_event(self.path, body)
|
||||||
elif self.path == "/feishu/notify":
|
elif self.path == "/feishu/notify" or self.path.startswith("/feishu/notify/"):
|
||||||
status, payload = handle_notify(headers, body)
|
status, payload = handle_notify(self.path, headers, body)
|
||||||
else:
|
else:
|
||||||
status, payload = 404, {"code": 404, "msg": "not found"}
|
status, payload = 404, {"code": 404, "msg": "not found"}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -365,16 +422,15 @@ def main() -> None:
|
|||||||
level=os.environ.get("LOG_LEVEL", "INFO"),
|
level=os.environ.get("LOG_LEVEL", "INFO"),
|
||||||
format="%(asctime)s %(levelname)s %(message)s",
|
format="%(asctime)s %(levelname)s %(message)s",
|
||||||
)
|
)
|
||||||
missing = [
|
missing = []
|
||||||
name
|
if not Config.feishu_apps:
|
||||||
for name, value in {
|
missing.append("FEISHU_APP_ID and FEISHU_APP_SECRET")
|
||||||
"FEISHU_APP_ID": Config.feishu_app_id,
|
for app_id, app in Config.feishu_apps.items():
|
||||||
"FEISHU_APP_SECRET": Config.feishu_app_secret,
|
if not app.get("app_secret"):
|
||||||
}.items()
|
missing.append(f"FEISHU_APP_SECRET for {app_id}")
|
||||||
if not value
|
|
||||||
]
|
|
||||||
if missing:
|
if missing:
|
||||||
logging.warning("missing required env: %s", ", ".join(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)
|
httpd = ThreadingHTTPServer(("0.0.0.0", Config.port), Handler)
|
||||||
logging.info("Feishu bridge listening on :%s", Config.port)
|
logging.info("Feishu bridge listening on :%s", Config.port)
|
||||||
httpd.serve_forever()
|
httpd.serve_forever()
|
||||||
|
|||||||
Reference in New Issue
Block a user