| |
| """ |
| 飞书图片代理服务器 (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) |
|
|
| |
|
|
| @app.route('/open-apis/im/v1/images/<image_key>', methods=['GET']) |
| def proxy_image(image_key): |
| """代理图片请求,修复认证方式""" |
| |
| token = request.args.get('tenant_access_token', '') |
| if not token: |
| |
| 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]}...") |
|
|
| |
| 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) |
|
|
| @app.route('/open-apis/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH']) |
| def proxy_open_apis(path): |
| """代理 /open-apis/* 请求(透传,修复 token)""" |
| return _proxy_to_feishu(f"/open-apis/{path}") |
|
|
| @app.route('/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH']) |
| 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 |
| ) |
| |
| 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) |
|
|
| @app.route('/health') |
| def health(): |
| return "ok" |
|
|
| |
|
|
| 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": |
| |
| 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_"): |
| |
| 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> # 直接下载图片") |
|
|