Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """ | |
| 飞书图片代理服务器 (image_proxy.py) v3 | |
| 作为 HTTP 代理运行,修复 feishu-openclaw 插件的图片认证问题。 | |
| 问题: feishu-openclaw 插件把 tenant_access_token 放在 URL query 参数中, | |
| 但飞书 API 要求放在 Authorization header 里,导致图片下载 400 错误。 | |
| 方案: 本地代理服务监听 8765 端口,接收插件的图片请求,把 token 从 query 参数 | |
| 移到 Authorization header 中再转发给飞书 API。 | |
| """ | |
| import os, sys, json, requests, time, threading | |
| from flask import Flask, request, Response, make_response | |
| FEISHU_BASE = "https://open.feishu.cn/open-apis" | |
| PORT = int(os.environ.get("IMAGE_PROXY_PORT", "8765")) | |
| app = Flask(__name__) | |
| def log(msg): | |
| ts = time.strftime("%H:%M:%S") | |
| print(f"[image_proxy {ts}] {msg}", flush=True) | |
| # ============ HTTP 代理模式 ============ | |
| def proxy_image(image_key): | |
| """代理图片请求,修复认证方式""" | |
| # 从 query 参数获取 token | |
| token = request.args.get('tenant_access_token', '') | |
| if not token: | |
| # 也尝试从 Authorization header 获取 | |
| auth_header = request.headers.get('Authorization', '') | |
| if auth_header.startswith('Bearer '): | |
| token = auth_header[7:] | |
| if not token: | |
| log(f"❌ 无 token: {image_key}") | |
| return make_response("Missing token", 401) | |
| log(f"📥 代理图片请求: {image_key[:30]}...") | |
| # 用正确的 Authorization header 转发给飞书 | |
| headers = {"Authorization": f"Bearer {token}"} | |
| try: | |
| resp = requests.get( | |
| f"{FEISHU_BASE}/im/v1/images/{image_key}", | |
| headers=headers, | |
| timeout=30, | |
| stream=True | |
| ) | |
| if resp.status_code == 200: | |
| content_type = resp.headers.get('Content-Type', 'image/jpeg') | |
| log(f"✅ 图片获取成功: {image_key[:30]}... ({len(resp.content)} bytes)") | |
| return Response( | |
| resp.content, | |
| status=200, | |
| content_type=content_type, | |
| headers={ | |
| 'Content-Length': str(len(resp.content)), | |
| 'Cache-Control': 'max-age=3600' | |
| } | |
| ) | |
| else: | |
| log(f"❌ 飞书返回 {resp.status_code}: {resp.text[:200]}") | |
| return make_response(resp.text, resp.status_code) | |
| except Exception as e: | |
| log(f"❌ 请求失败: {e}") | |
| return make_response(str(e), 502) | |
| def proxy_open_apis(path): | |
| """代理 /open-apis/* 请求(透传,修复 token)""" | |
| return _proxy_to_feishu(f"/open-apis/{path}") | |
| def proxy_catch_all(path): | |
| """catch-all: 透传所有其他请求(如 /callback/ws/endpoint)""" | |
| return _proxy_to_feishu(f"/{path}") | |
| def _proxy_to_feishu(path): | |
| """通用代理:转发请求到 open.feishu.cn""" | |
| token = request.args.get('tenant_access_token', '') | |
| headers = {k: v for k, v in request.headers if k.lower() not in ('host', 'content-length')} | |
| if token and 'Authorization' not in headers: | |
| headers['Authorization'] = f'Bearer {token}' | |
| url = f"https://open.feishu.cn{path}" | |
| params = {k: v for k, v in request.args.items() if k != 'tenant_access_token'} | |
| try: | |
| resp = requests.request( | |
| method=request.method, | |
| url=url, | |
| headers=headers, | |
| params=params, | |
| data=request.get_data(), | |
| timeout=30 | |
| ) | |
| # 透传所有响应 headers | |
| excluded_headers = {'content-encoding', 'content-length', 'transfer-encoding', 'connection'} | |
| response_headers = {k: v for k, v in resp.headers.items() if k.lower() not in excluded_headers} | |
| return Response(resp.content, status=resp.status_code, | |
| headers=response_headers, | |
| content_type=resp.headers.get('Content-Type', 'application/json')) | |
| except Exception as e: | |
| log(f"❌ 代理失败 {path}: {e}") | |
| return make_response(str(e), 502) | |
| def health(): | |
| return "ok" | |
| # ============ CLI 模式(保持向后兼容)============ | |
| def get_tenant_token(): | |
| app_id = os.environ.get("FEISHU_APP_ID") | |
| app_secret = os.environ.get("FEISHU_APP_SECRET") | |
| if not app_id or not app_secret: | |
| print("ERROR: FEISHU_APP_ID / FEISHU_APP_SECRET 未设置", file=sys.stderr) | |
| sys.exit(1) | |
| resp = requests.post(f"{FEISHU_BASE}/auth/v3/tenant_access_token/internal", | |
| json={"app_id": app_id, "app_secret": app_secret}) | |
| data = resp.json() | |
| if data.get("code") != 0: | |
| print(f"ERROR: 获取 token 失败: {data}", file=sys.stderr) | |
| sys.exit(1) | |
| return data["tenant_access_token"] | |
| def download_image(image_key, token): | |
| resp = requests.get(f"{FEISHU_BASE}/im/v1/images/{image_key}", | |
| headers={"Authorization": f"Bearer {token}"}, stream=True) | |
| if resp.status_code != 200: | |
| print(f"ERROR: 下载图片失败 (HTTP {resp.status_code}): {resp.text[:200]}", file=sys.stderr) | |
| return None | |
| return resp.content | |
| def upload_image(image_data): | |
| for name, fn in [("catbox", upload_to_catbox), ("0x0", upload_to_0x0), ("tmpfiles", upload_to_tmpfiles)]: | |
| url = fn(image_data) | |
| if url: | |
| return url | |
| return None | |
| def upload_to_catbox(data): | |
| try: | |
| resp = requests.post("https://catbox.moe/user/api.php", | |
| data={"reqtype": "fileupload"}, | |
| files={"filedata": ("img.jpg", data, "image/jpeg")}, timeout=30) | |
| if resp.status_code == 200 and resp.text.startswith("http"): | |
| return resp.text.strip() | |
| except: pass | |
| return None | |
| def upload_to_0x0(data): | |
| try: | |
| resp = requests.post("https://0x0.st", | |
| files={"file": ("img.jpg", data, "image/jpeg")}, timeout=30) | |
| if resp.status_code == 200 and resp.text.startswith("http"): | |
| return resp.text.strip() | |
| except: pass | |
| return None | |
| def upload_to_tmpfiles(data): | |
| try: | |
| resp = requests.post("https://tmpfiles.org/api/v1/upload", | |
| files={"file": ("img.jpg", data, "image/jpeg")}, timeout=30) | |
| if resp.status_code == 200: | |
| url = resp.json().get("data", {}).get("url", "") | |
| if url: | |
| return url.replace("tmpfiles.org/", "tmpfiles.org/dl/") | |
| except: pass | |
| return None | |
| if __name__ == "__main__": | |
| if len(sys.argv) >= 2 and sys.argv[1] == "--server": | |
| # HTTP 代理服务器模式 | |
| log(f"🚀 图片代理服务器启动 (端口 {PORT})") | |
| app.run(host="127.0.0.1", port=PORT, threaded=True) | |
| elif len(sys.argv) >= 2 and sys.argv[1].startswith("img_"): | |
| # CLI 模式:直接下载指定图片 | |
| token = get_tenant_token() | |
| image_key = sys.argv[1] | |
| data = download_image(image_key, token) | |
| if data: | |
| url = upload_image(data) | |
| if url: | |
| print(url) | |
| else: | |
| path = f"/tmp/{image_key}.jpg" | |
| with open(path, "wb") as f: | |
| f.write(data) | |
| print(path) | |
| else: | |
| print("用法:") | |
| print(" python3 image_proxy.py --server # 启动代理服务器") | |
| print(" python3 image_proxy.py <image_key> # 直接下载图片") | |