auto-save 2026-05-09 16:40 (~7)

This commit is contained in:
2026-05-09 16:40:34 +08:00
parent 211ce9de23
commit af00c61db6
7 changed files with 158 additions and 74 deletions

View File

@@ -15,6 +15,7 @@ import threading
import time
import traceback
import urllib.error
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
@@ -27,19 +28,42 @@ def _env(name: str, default: str = "") -> str:
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:
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")
feishu_apps = _load_feishu_apps()
default_feishu_app_id = _env("FEISHU_DEFAULT_APP_ID", feishu_app_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"}
reply_in_thread = _bool_env("FEISHU_REPLY_IN_THREAD")
hermes_api_base = _env("HERMES_API_BASE", "http://127.0.0.1:8642/v1").rstrip("/")
hermes_api_key = _env("HERMES_API_KEY")
@@ -55,20 +79,23 @@ class Config:
class TokenCache:
def __init__(self) -> None:
self._lock = threading.Lock()
self._token = ""
self._expires_at = 0.0
self._tokens: dict[str, tuple[str, float]] = {}
def get(self) -> str:
def get(self, app_id: str) -> str:
with self._lock:
if self._token and time.time() < self._expires_at - 300:
return self._token
token, expires_at = self._tokens.get(app_id, ("", 0.0))
if token and time.time() < expires_at - 300:
return token
if not Config.feishu_app_id or not Config.feishu_app_secret:
raise RuntimeError("FEISHU_APP_ID and FEISHU_APP_SECRET are required")
app = Config.feishu_apps.get(app_id)
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 = {
"app_id": Config.feishu_app_id,
"app_secret": Config.feishu_app_secret,
"app_id": app["app_id"],
"app_secret": app["app_secret"],
}
data = http_json(
"POST",
@@ -78,9 +105,10 @@ class TokenCache:
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 = str(data["tenant_access_token"])
expires_at = time.time() + int(data.get("expire", 7200))
self._tokens[app_id] = (token, expires_at)
return token
token_cache = TokenCache()
@@ -131,8 +159,25 @@ def json_text(value: Any) -> str:
return ""
def verify_callback_token(body: dict[str, Any]) -> bool:
expected = Config.feishu_verification_token
def resolve_app_id(path: str, body: dict[str, Any] | None = None) -> str:
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:
return True
token = body.get("token") or body.get("header", {}).get("token")
@@ -153,7 +198,11 @@ def remember_event(event_id: str) -> bool:
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:
return 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 not verify_callback_token(body):
if not verify_callback_token(body, app_id):
return 403, {"code": 403, "msg": "invalid verification token"}
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"}
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):
if not remember_event(f"{app_id}:{event_id}"):
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"}
def process_message_event(body: dict[str, Any]) -> None:
def process_message_event(app_id: str, body: dict[str, Any]) -> None:
try:
app = Config.feishu_apps[app_id]
event = body.get("event", {})
sender = event.get("sender", {})
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")
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)
allowed_chat_ids = app.get("allowed_chat_ids", set())
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
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
text = json_text(message.get("content"))
if not text:
send_feishu_text(chat_id, "我没有读到文本内容。", message_id=message_id)
send_feishu_text(app_id, chat_id, "我没有读到文本内容。", message_id=message_id)
return
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:
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(
app_id: str,
receive_id: str,
text: str,
receive_id_type: str = "chat_id",
@@ -250,7 +302,7 @@ def send_feishu_text(
if not receive_id:
raise RuntimeError("receive_id is required")
token = token_cache.get()
token = token_cache.get(app_id)
chunks = split_text(text)
result: dict[str, Any] = {}
for chunk in chunks:
@@ -288,7 +340,7 @@ def split_text(text: str, limit: int = 3800) -> list[str]:
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
if not expected:
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:
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()
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 = str(body.get("receive_id") or app.get("default_receive_id", "")).strip()
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()
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}
result = send_feishu_text(app_id, receive_id, text, receive_id_type=receive_id_type)
return 200, {"code": 0, "msg": "ok", "app_id": app_id, "feishu": result}
class Handler(BaseHTTPRequestHandler):
@@ -327,10 +384,10 @@ class Handler(BaseHTTPRequestHandler):
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)
if self.path == "/feishu/events" or self.path.startswith("/feishu/events/"):
status, payload = handle_feishu_event(self.path, body)
elif self.path == "/feishu/notify" or self.path.startswith("/feishu/notify/"):
status, payload = handle_notify(self.path, headers, body)
else:
status, payload = 404, {"code": 404, "msg": "not found"}
except Exception as exc:
@@ -365,16 +422,15 @@ def main() -> None:
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
]
missing = []
if not Config.feishu_apps:
missing.append("FEISHU_APP_ID and FEISHU_APP_SECRET")
for app_id, app in Config.feishu_apps.items():
if not app.get("app_secret"):
missing.append(f"FEISHU_APP_SECRET for {app_id}")
if 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)
logging.info("Feishu bridge listening on :%s", Config.port)
httpd.serve_forever()