File size: 7,541 Bytes
cd89f2e
 
fdf9084
 
 
 
 
 
 
cd89f2e
fdf9084
 
32aeb2a
 
fdf9084
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f1218bb
 
 
 
 
 
 
 
 
 
 
 
fdf9084
f1218bb
fdf9084
 
 
 
f1218bb
fdf9084
 
 
 
 
 
 
 
 
 
 
f1218bb
 
 
fdf9084
f1218bb
fdf9084
 
f1218bb
fdf9084
 
 
 
 
 
 
cd89f2e
 
 
 
 
 
 
32aeb2a
 
cd89f2e
 
 
 
 
 
 
32aeb2a
 
cd89f2e
 
32aeb2a
cd89f2e
 
fdf9084
 
 
 
 
 
 
 
32aeb2a
 
 
fdf9084
32aeb2a
 
fdf9084
cd89f2e
 
fdf9084
32aeb2a
 
fdf9084
32aeb2a
 
fdf9084
cd89f2e
 
fdf9084
32aeb2a
 
fdf9084
32aeb2a
 
 
 
fdf9084
cd89f2e
 
 
fdf9084
 
 
 
 
 
 
32aeb2a
fdf9084
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
#!/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>   # 直接下载图片")