#!/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 代理模式 ============ @app.route('/open-apis/im/v1/images/', methods=['GET']) 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) @app.route('/open-apis/', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH']) def proxy_open_apis(path): """代理 /open-apis/* 请求(透传,修复 token)""" return _proxy_to_feishu(f"/open-apis/{path}") @app.route('/', 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 ) # 透传所有响应 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) @app.route('/health') 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 # 直接下载图片")