| | |
| | """ |
| | 飞书图片代理服务器 (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> # 直接下载图片") |
| |
|