MoltBotXY / image_proxy.py
asemxin
fix: add catch-all proxy route for /callback/*
d08373b
#!/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/<image_key>', 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/<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
)
# 透传所有响应 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 <image_key> # 直接下载图片")