asemxin commited on
Commit
159235c
·
1 Parent(s): 0e981c2

feat: image proxy server + plugin JS patch for auth fix

Browse files
Files changed (2) hide show
  1. entrypoint.sh +24 -0
  2. image_proxy.py +135 -128
entrypoint.sh CHANGED
@@ -253,6 +253,29 @@ for fpath in glob.glob(os.path.join(agent_dir, "*")):
253
 
254
  PYEOF
255
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  # ============================================
257
  # 启动 OpenClaw Gateway(后台)
258
  # ============================================
@@ -278,3 +301,4 @@ echo " Image Daemon PID: $IMAGE_DAEMON_PID"
278
  echo "📊 启动状态监控网页 (端口 7860)..."
279
  exec python3 /app/status_page.py
280
 
 
 
253
 
254
  PYEOF
255
 
256
+ # ============================================
257
+ # 启动图片代理服务器(后台,端口 8765)
258
+ # 修复 feishu-openclaw 插件把 token 放在 URL query 参数的 bug
259
+ # ============================================
260
+ echo "🔧 启动飞书图片代理服务器..."
261
+ python3 /app/image_proxy.py --server &
262
+ IMAGE_PROXY_PID=$!
263
+ echo " Image Proxy PID: $IMAGE_PROXY_PID"
264
+ sleep 1
265
+
266
+ # ============================================
267
+ # Patch feishu-openclaw 插件:图片请求改走本地代理
268
+ # ============================================
269
+ PLUGIN_JS="$HOME/.openclaw/extensions/feishu-openclaw/dist/index.openclaw.js"
270
+ if [ -f "$PLUGIN_JS" ]; then
271
+ echo "🔧 Patch 飞书插件图片 URL..."
272
+ # 替换飞书 API base URL 为本地代理(只针对图片请求)
273
+ sed -i 's|https://open.feishu.cn/open-apis|http://127.0.0.1:8765/open-apis|g' "$PLUGIN_JS"
274
+ echo " ✅ 插件已 patch,图片请求将走本地代理"
275
+ else
276
+ echo " ⚠️ 插件 JS 不存在: $PLUGIN_JS"
277
+ fi
278
+
279
  # ============================================
280
  # 启动 OpenClaw Gateway(后台)
281
  # ============================================
 
301
  echo "📊 启动状态监控网页 (端口 7860)..."
302
  exec python3 /app/status_page.py
303
 
304
+
image_proxy.py CHANGED
@@ -1,14 +1,106 @@
1
  #!/usr/bin/env python3
2
  """
3
- 飞书图片代理 v2
4
- 用法:
5
- python3 image_proxy.py <image_key> # 直接用 image_key 下载
6
- python3 image_proxy.py --chat <chat_id> # 自动查找该聊天最近的图片
7
- python3 image_proxy.py --recent # 查找所有聊天中最近收到的图片
 
 
8
  """
9
- import os, sys, json, requests, time
 
10
 
11
  FEISHU_BASE = "https://open.feishu.cn/open-apis"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  def get_tenant_token():
14
  app_id = os.environ.get("FEISHU_APP_ID")
@@ -24,55 +116,6 @@ def get_tenant_token():
24
  sys.exit(1)
25
  return data["tenant_access_token"]
26
 
27
- def find_image_keys_in_chat(token, chat_id, limit=20):
28
- """从指定聊天中查找最近的图片消息"""
29
- headers = {"Authorization": f"Bearer {token}"}
30
- resp = requests.get(f"{FEISHU_BASE}/im/v1/messages",
31
- headers=headers,
32
- params={"container_id_type": "chat", "container_id": chat_id,
33
- "sort_type": "ByCreateTimeDesc", "page_size": limit})
34
- data = resp.json()
35
- if data.get("code") != 0:
36
- print(f"ERROR: 获取消息列表失败: {data}", file=sys.stderr)
37
- return []
38
-
39
- image_keys = []
40
- for item in data.get("data", {}).get("items", []):
41
- msg_type = item.get("msg_type", "")
42
- body = item.get("body", {}).get("content", "{}")
43
- try:
44
- content = json.loads(body) if isinstance(body, str) else body
45
- except:
46
- continue
47
-
48
- if msg_type == "image":
49
- key = content.get("image_key", "")
50
- if key:
51
- image_keys.append(key)
52
- elif msg_type == "post": # rich text can contain images
53
- # Parse post format for image keys
54
- for lang_content in (content.get("zh_cn", content.get("en_us", content)) if isinstance(content, dict) else []):
55
- if isinstance(lang_content, dict):
56
- for row in lang_content.get("content", []):
57
- if isinstance(row, list):
58
- for elem in row:
59
- if isinstance(elem, dict) and elem.get("tag") == "img":
60
- key = elem.get("image_key", "")
61
- if key:
62
- image_keys.append(key)
63
- return image_keys
64
-
65
- def find_recent_chats(token):
66
- """获取 bot 最近的聊天列表"""
67
- headers = {"Authorization": f"Bearer {token}"}
68
- resp = requests.get(f"{FEISHU_BASE}/im/v1/chats",
69
- headers=headers, params={"page_size": 10, "sort_type": "ByCreateTimeDesc"})
70
- data = resp.json()
71
- if data.get("code") != 0:
72
- print(f"ERROR: 获取聊天列表失败: {data}", file=sys.stderr)
73
- return []
74
- return [item.get("chat_id") for item in data.get("data", {}).get("items", [])]
75
-
76
  def download_image(image_key, token):
77
  resp = requests.get(f"{FEISHU_BASE}/im/v1/images/{image_key}",
78
  headers={"Authorization": f"Bearer {token}"}, stream=True)
@@ -81,99 +124,63 @@ def download_image(image_key, token):
81
  return None
82
  return resp.content
83
 
84
- def upload_to_catbox(image_data):
 
 
 
 
 
 
 
85
  try:
86
  resp = requests.post("https://catbox.moe/user/api.php",
87
  data={"reqtype": "fileupload"},
88
- files={"filedata": ("image.jpg", image_data, "image/jpeg")},
89
- timeout=30)
90
  if resp.status_code == 200 and resp.text.startswith("http"):
91
  return resp.text.strip()
92
- except Exception as e:
93
- print(f"⚠️ catbox 失败: {e}", file=sys.stderr)
94
  return None
95
 
96
- def upload_to_0x0(image_data):
97
  try:
98
  resp = requests.post("https://0x0.st",
99
- files={"file": ("image.jpg", image_data, "image/jpeg")}, timeout=30)
100
  if resp.status_code == 200 and resp.text.startswith("http"):
101
  return resp.text.strip()
102
- except Exception as e:
103
- print(f"⚠️ 0x0 失败: {e}", file=sys.stderr)
104
  return None
105
 
106
- def upload_to_tmpfiles(image_data):
107
  try:
108
  resp = requests.post("https://tmpfiles.org/api/v1/upload",
109
- files={"file": ("image.jpg", image_data, "image/jpeg")}, timeout=30)
110
  if resp.status_code == 200:
111
  url = resp.json().get("data", {}).get("url", "")
112
  if url:
113
  return url.replace("tmpfiles.org/", "tmpfiles.org/dl/")
114
- except Exception as e:
115
- print(f"⚠️ tmpfiles 失败: {e}", file=sys.stderr)
116
- return None
117
-
118
- def upload_image(image_data):
119
- for name, fn in [("catbox", upload_to_catbox), ("0x0", upload_to_0x0), ("tmpfiles", upload_to_tmpfiles)]:
120
- print(f"📤 上传到 {name}...", file=sys.stderr)
121
- url = fn(image_data)
122
- if url:
123
- print(f"✅ 上传成功: {url}", file=sys.stderr)
124
- return url
125
  return None
126
 
127
- def process_image_key(image_key, token):
128
- print(f"📥 下载飞书图片: {image_key}", file=sys.stderr)
129
- data = download_image(image_key, token)
130
- if not data:
131
- return None
132
- print(f"📥 下载成功 ({len(data)} bytes)", file=sys.stderr)
133
- url = upload_image(data)
134
- if url:
135
- print(url)
136
- return url
137
- # fallback: save locally
138
- path = f"/tmp/{image_key}.jpg"
139
- with open(path, "wb") as f:
140
- f.write(data)
141
- print(f"⚠️ 图床失败,本地保存: {path}", file=sys.stderr)
142
- print(path)
143
- return path
144
-
145
  if __name__ == "__main__":
146
- if len(sys.argv) < 2:
147
- print("用法:")
148
- print(" python3 image_proxy.py <image_key>")
149
- print(" python3 image_proxy.py --chat <chat_id>")
150
- print(" python3 image_proxy.py --recent")
151
- sys.exit(1)
152
-
153
- token = get_tenant_token()
154
-
155
- if sys.argv[1] == "--chat" and len(sys.argv) >= 3:
156
- chat_id = sys.argv[2]
157
- print(f"🔍 在聊天 {chat_id} 中查找图片...", file=sys.stderr)
158
- keys = find_image_keys_in_chat(token, chat_id)
159
- if not keys:
160
- print("❌ 未找到图片", file=sys.stderr)
161
- sys.exit(1)
162
- print(f"🔍 找到 {len(keys)} 张图片,处理第一张: {keys[0]}", file=sys.stderr)
163
- process_image_key(keys[0], token)
164
-
165
- elif sys.argv[1] == "--recent":
166
- print("🔍 查找最近聊天中的图片...", file=sys.stderr)
167
- chats = find_recent_chats(token)
168
- for chat_id in chats:
169
- keys = find_image_keys_in_chat(token, chat_id, limit=5)
170
- if keys:
171
- print(f"🔍 在 {chat_id} 找到图片: {keys[0]}", file=sys.stderr)
172
- process_image_key(keys[0], token)
173
- sys.exit(0)
174
- print("❌ 所有聊天中均未找到图片", file=sys.stderr)
175
- sys.exit(1)
176
-
177
- else:
178
  image_key = sys.argv[1]
179
- process_image_key(image_key, token)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  #!/usr/bin/env python3
2
  """
3
+ 飞书图片代理服务器 (image_proxy.py) v3
4
+ 作为 HTTP 代理运行,修复 feishu-openclaw 插件的图片认证问题。
5
+
6
+ 问题: feishu-openclaw 插件把 tenant_access_token 放在 URL query 参数
7
+ 但飞书 API 要求放在 Authorization header 里,导致图片下载 400 错误。
8
+ 方案: 本地代理服务监听 8765 端口,接收插件的图片请求,把 token 从 query 参数
9
+ 移到 Authorization header 中再转发给飞书 API。
10
  """
11
+ import os, sys, json, requests, time, threading
12
+ from flask import Flask, request, Response, make_response
13
 
14
  FEISHU_BASE = "https://open.feishu.cn/open-apis"
15
+ PORT = int(os.environ.get("IMAGE_PROXY_PORT", "8765"))
16
+
17
+ app = Flask(__name__)
18
+
19
+ def log(msg):
20
+ ts = time.strftime("%H:%M:%S")
21
+ print(f"[image_proxy {ts}] {msg}", flush=True)
22
+
23
+ # ============ HTTP 代理模式 ============
24
+
25
+ @app.route('/open-apis/im/v1/images/<image_key>', methods=['GET'])
26
+ def proxy_image(image_key):
27
+ """代理图片请求,修复认证方式"""
28
+ # 从 query 参数获取 token
29
+ token = request.args.get('tenant_access_token', '')
30
+ if not token:
31
+ # 也尝试从 Authorization header 获取
32
+ auth_header = request.headers.get('Authorization', '')
33
+ if auth_header.startswith('Bearer '):
34
+ token = auth_header[7:]
35
+
36
+ if not token:
37
+ log(f"❌ 无 token: {image_key}")
38
+ return make_response("Missing token", 401)
39
+
40
+ log(f"📥 代理图片请求: {image_key[:30]}...")
41
+
42
+ # 用正确的 Authorization header 转发给飞书
43
+ headers = {"Authorization": f"Bearer {token}"}
44
+ try:
45
+ resp = requests.get(
46
+ f"{FEISHU_BASE}/im/v1/images/{image_key}",
47
+ headers=headers,
48
+ timeout=30,
49
+ stream=True
50
+ )
51
+
52
+ if resp.status_code == 200:
53
+ content_type = resp.headers.get('Content-Type', 'image/jpeg')
54
+ log(f"✅ 图片获取成功: {image_key[:30]}... ({len(resp.content)} bytes)")
55
+ return Response(
56
+ resp.content,
57
+ status=200,
58
+ content_type=content_type,
59
+ headers={
60
+ 'Content-Length': str(len(resp.content)),
61
+ 'Cache-Control': 'max-age=3600'
62
+ }
63
+ )
64
+ else:
65
+ log(f"❌ 飞书返回 {resp.status_code}: {resp.text[:200]}")
66
+ return make_response(resp.text, resp.status_code)
67
+
68
+ except Exception as e:
69
+ log(f"❌ 请求失败: {e}")
70
+ return make_response(str(e), 502)
71
+
72
+ @app.route('/open-apis/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE'])
73
+ def proxy_other(path):
74
+ """代理其他飞书 API 请求(透传)"""
75
+ token = request.args.get('tenant_access_token', '')
76
+ headers = dict(request.headers)
77
+ headers.pop('Host', None)
78
+
79
+ if token and 'Authorization' not in headers:
80
+ headers['Authorization'] = f'Bearer {token}'
81
+
82
+ url = f"{FEISHU_BASE}/{path}"
83
+ params = {k: v for k, v in request.args.items() if k != 'tenant_access_token'}
84
+
85
+ try:
86
+ resp = requests.request(
87
+ method=request.method,
88
+ url=url,
89
+ headers=headers,
90
+ params=params,
91
+ data=request.get_data(),
92
+ timeout=30
93
+ )
94
+ return Response(resp.content, status=resp.status_code,
95
+ content_type=resp.headers.get('Content-Type', 'application/json'))
96
+ except Exception as e:
97
+ return make_response(str(e), 502)
98
+
99
+ @app.route('/health')
100
+ def health():
101
+ return "ok"
102
+
103
+ # ============ CLI 模式(保持向后兼容)============
104
 
105
  def get_tenant_token():
106
  app_id = os.environ.get("FEISHU_APP_ID")
 
116
  sys.exit(1)
117
  return data["tenant_access_token"]
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  def download_image(image_key, token):
120
  resp = requests.get(f"{FEISHU_BASE}/im/v1/images/{image_key}",
121
  headers={"Authorization": f"Bearer {token}"}, stream=True)
 
124
  return None
125
  return resp.content
126
 
127
+ def upload_image(image_data):
128
+ for name, fn in [("catbox", upload_to_catbox), ("0x0", upload_to_0x0), ("tmpfiles", upload_to_tmpfiles)]:
129
+ url = fn(image_data)
130
+ if url:
131
+ return url
132
+ return None
133
+
134
+ def upload_to_catbox(data):
135
  try:
136
  resp = requests.post("https://catbox.moe/user/api.php",
137
  data={"reqtype": "fileupload"},
138
+ files={"filedata": ("img.jpg", data, "image/jpeg")}, timeout=30)
 
139
  if resp.status_code == 200 and resp.text.startswith("http"):
140
  return resp.text.strip()
141
+ except: pass
 
142
  return None
143
 
144
+ def upload_to_0x0(data):
145
  try:
146
  resp = requests.post("https://0x0.st",
147
+ files={"file": ("img.jpg", data, "image/jpeg")}, timeout=30)
148
  if resp.status_code == 200 and resp.text.startswith("http"):
149
  return resp.text.strip()
150
+ except: pass
 
151
  return None
152
 
153
+ def upload_to_tmpfiles(data):
154
  try:
155
  resp = requests.post("https://tmpfiles.org/api/v1/upload",
156
+ files={"file": ("img.jpg", data, "image/jpeg")}, timeout=30)
157
  if resp.status_code == 200:
158
  url = resp.json().get("data", {}).get("url", "")
159
  if url:
160
  return url.replace("tmpfiles.org/", "tmpfiles.org/dl/")
161
+ except: pass
 
 
 
 
 
 
 
 
 
 
162
  return None
163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  if __name__ == "__main__":
165
+ if len(sys.argv) >= 2 and sys.argv[1] == "--server":
166
+ # HTTP 代理服务器模式
167
+ log(f"🚀 图片代理服务器启动 (端口 {PORT})")
168
+ app.run(host="127.0.0.1", port=PORT, threaded=True)
169
+ elif len(sys.argv) >= 2 and sys.argv[1].startswith("img_"):
170
+ # CLI 模式:直接下载指定图片
171
+ token = get_tenant_token()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  image_key = sys.argv[1]
173
+ data = download_image(image_key, token)
174
+ if data:
175
+ url = upload_image(data)
176
+ if url:
177
+ print(url)
178
+ else:
179
+ path = f"/tmp/{image_key}.jpg"
180
+ with open(path, "wb") as f:
181
+ f.write(data)
182
+ print(path)
183
+ else:
184
+ print("用法:")
185
+ print(" python3 image_proxy.py --server # 启动代理服务器")
186
+ print(" python3 image_proxy.py <image_key> # 直接下载图片")