asemxin commited on
Commit
7eb8e6d
·
1 Parent(s): f421efd

feat: add image_daemon.py system-level preprocessor

Browse files
Files changed (3) hide show
  1. Dockerfile +1 -0
  2. entrypoint.sh +8 -0
  3. image_daemon.py +218 -0
Dockerfile CHANGED
@@ -22,6 +22,7 @@ RUN cd /root/.openclaw/extensions/feishu-openclaw && npm install @sinclair/typeb
22
  COPY SOUL.md /root/.openclaw/workspace/SOUL.md
23
  COPY status_page.py /app/status_page.py
24
  COPY image_proxy.py /app/image_proxy.py
 
25
  COPY skills/ /root/.openclaw/skills/
26
  COPY entrypoint.sh /app/entrypoint.sh
27
  RUN chmod +x /app/entrypoint.sh
 
22
  COPY SOUL.md /root/.openclaw/workspace/SOUL.md
23
  COPY status_page.py /app/status_page.py
24
  COPY image_proxy.py /app/image_proxy.py
25
+ COPY image_daemon.py /app/image_daemon.py
26
  COPY skills/ /root/.openclaw/skills/
27
  COPY entrypoint.sh /app/entrypoint.sh
28
  RUN chmod +x /app/entrypoint.sh
entrypoint.sh CHANGED
@@ -264,6 +264,14 @@ echo " Gateway PID: $GATEWAY_PID"
264
  # 等待网关启动
265
  sleep 5
266
 
 
 
 
 
 
 
 
 
267
  # ============================================
268
  # 启动状态监控网页(前台,端口 7860)
269
  # ============================================
 
264
  # 等待网关启动
265
  sleep 5
266
 
267
+ # ============================================
268
+ # 启动图片预处理守护进程(后台)
269
+ # ============================================
270
+ echo "🖼️ 启动图片预处理守护进程..."
271
+ python3 /app/image_daemon.py &
272
+ IMAGE_DAEMON_PID=$!
273
+ echo " Image Daemon PID: $IMAGE_DAEMON_PID"
274
+
275
  # ============================================
276
  # 启动状态监控网页(前台,端口 7860)
277
  # ============================================
image_daemon.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ 飞书图片预处理守护进程 (image_daemon.py)
4
+ 后台运行,自动检测飞书聊天中的新图片消息,下载并上传到图床,
5
+ 然后以文本消息回复图片 URL,让 OpenClaw agent 直接看到可访问的 URL。
6
+ """
7
+ import os, sys, json, time, requests, hashlib
8
+
9
+ FEISHU_BASE = "https://open.feishu.cn/open-apis"
10
+ POLL_INTERVAL = int(os.environ.get("IMAGE_POLL_INTERVAL", "3"))
11
+ processed_file = "/tmp/image_daemon_processed.json"
12
+
13
+ def log(msg):
14
+ print(f"[image_daemon] {msg}", flush=True)
15
+
16
+ def load_processed():
17
+ try:
18
+ with open(processed_file) as f:
19
+ return set(json.load(f))
20
+ except:
21
+ return set()
22
+
23
+ def save_processed(s):
24
+ with open(processed_file, "w") as f:
25
+ json.dump(list(s)[-500:], f) # keep last 500
26
+
27
+ def get_tenant_token():
28
+ app_id = os.environ.get("FEISHU_APP_ID")
29
+ app_secret = os.environ.get("FEISHU_APP_SECRET")
30
+ resp = requests.post(f"{FEISHU_BASE}/auth/v3/tenant_access_token/internal",
31
+ json={"app_id": app_id, "app_secret": app_secret}, timeout=10)
32
+ data = resp.json()
33
+ if data.get("code") != 0:
34
+ raise Exception(f"token error: {data}")
35
+ return data["tenant_access_token"]
36
+
37
+ def get_bot_chats(token):
38
+ """获取 bot 参与的所有聊天"""
39
+ headers = {"Authorization": f"Bearer {token}"}
40
+ resp = requests.get(f"{FEISHU_BASE}/im/v1/chats",
41
+ headers=headers, params={"page_size": 20}, timeout=10)
42
+ data = resp.json()
43
+ if data.get("code") != 0:
44
+ return []
45
+ return [c["chat_id"] for c in data.get("data", {}).get("items", [])]
46
+
47
+ def get_recent_messages(token, chat_id, limit=10):
48
+ """获取聊天中最近的消息"""
49
+ headers = {"Authorization": f"Bearer {token}"}
50
+ resp = requests.get(f"{FEISHU_BASE}/im/v1/messages",
51
+ headers=headers,
52
+ params={
53
+ "container_id_type": "chat",
54
+ "container_id": chat_id,
55
+ "sort_type": "ByCreateTimeDesc",
56
+ "page_size": limit
57
+ }, timeout=10)
58
+ data = resp.json()
59
+ if data.get("code") != 0:
60
+ return []
61
+ return data.get("data", {}).get("items", [])
62
+
63
+ def extract_image_keys(msg):
64
+ """从消息中提取所有 image_key"""
65
+ msg_type = msg.get("msg_type", "")
66
+ body_str = msg.get("body", {}).get("content", "{}")
67
+ try:
68
+ content = json.loads(body_str)
69
+ except:
70
+ return []
71
+
72
+ keys = []
73
+ if msg_type == "image":
74
+ k = content.get("image_key", "")
75
+ if k:
76
+ keys.append(k)
77
+ elif msg_type == "post":
78
+ # rich text / forwarded messages
79
+ for lang in ["zh_cn", "en_us"]:
80
+ lang_content = content.get(lang, {})
81
+ if isinstance(lang_content, dict):
82
+ for row in lang_content.get("content", []):
83
+ if isinstance(row, list):
84
+ for elem in row:
85
+ if isinstance(elem, dict) and elem.get("tag") == "img":
86
+ k = elem.get("image_key", "")
87
+ if k:
88
+ keys.append(k)
89
+ return keys
90
+
91
+ def download_image(token, image_key):
92
+ """从飞书下载图片"""
93
+ headers = {"Authorization": f"Bearer {token}"}
94
+ resp = requests.get(f"{FEISHU_BASE}/im/v1/images/{image_key}",
95
+ headers=headers, timeout=30)
96
+ if resp.status_code == 200 and len(resp.content) > 100:
97
+ return resp.content
98
+ return None
99
+
100
+ def upload_image(data):
101
+ """上传到免费图床"""
102
+ # catbox.moe
103
+ try:
104
+ resp = requests.post("https://catbox.moe/user/api.php",
105
+ data={"reqtype": "fileupload"},
106
+ files={"filedata": ("img.jpg", data, "image/jpeg")}, timeout=30)
107
+ if resp.status_code == 200 and resp.text.startswith("http"):
108
+ return resp.text.strip()
109
+ except:
110
+ pass
111
+ # 0x0.st
112
+ try:
113
+ resp = requests.post("https://0x0.st",
114
+ files={"file": ("img.jpg", data, "image/jpeg")}, timeout=30)
115
+ if resp.status_code == 200 and resp.text.startswith("http"):
116
+ return resp.text.strip()
117
+ except:
118
+ pass
119
+ # tmpfiles
120
+ try:
121
+ resp = requests.post("https://tmpfiles.org/api/v1/upload",
122
+ files={"file": ("img.jpg", data, "image/jpeg")}, timeout=30)
123
+ if resp.status_code == 200:
124
+ url = resp.json().get("data", {}).get("url", "")
125
+ if url:
126
+ return url.replace("tmpfiles.org/", "tmpfiles.org/dl/")
127
+ except:
128
+ pass
129
+ return None
130
+
131
+ def reply_text(token, chat_id, msg_id, text):
132
+ """在飞书聊天中回复文本消息"""
133
+ headers = {
134
+ "Authorization": f"Bearer {token}",
135
+ "Content-Type": "application/json; charset=utf-8"
136
+ }
137
+ # 作为回复发送
138
+ resp = requests.post(f"{FEISHU_BASE}/im/v1/messages/{msg_id}/reply",
139
+ headers=headers,
140
+ json={
141
+ "content": json.dumps({"text": text}),
142
+ "msg_type": "text"
143
+ }, timeout=10)
144
+ return resp.json()
145
+
146
+ def process_message(token, chat_id, msg):
147
+ """处理单条消息"""
148
+ msg_id = msg.get("message_id", "")
149
+ sender_type = msg.get("sender", {}).get("sender_type", "")
150
+
151
+ # 跳过 bot 自己发的消息
152
+ if sender_type == "app":
153
+ return
154
+
155
+ image_keys = extract_image_keys(msg)
156
+ if not image_keys:
157
+ return
158
+
159
+ urls = []
160
+ for key in image_keys[:3]: # 最多处理 3 张
161
+ log(f"📥 下载图片 {key}")
162
+ data = download_image(token, key)
163
+ if data:
164
+ log(f"📥 {len(data)} bytes, 上传中...")
165
+ url = upload_image(data)
166
+ if url:
167
+ urls.append(url)
168
+ log(f"✅ {url}")
169
+ else:
170
+ # 保存到本地
171
+ path = f"/tmp/{key}.jpg"
172
+ with open(path, "wb") as f:
173
+ f.write(data)
174
+ log(f"⚠️ 图床失败,本地保存: {path}")
175
+
176
+ if urls:
177
+ url_text = "\n".join(urls)
178
+ reply = f"[系统] 图片已自动处理,可通过以下链接查看:\n{url_text}"
179
+ result = reply_text(token, chat_id, msg_id, reply)
180
+ log(f"📤 已回复图片链接到聊天 (code={result.get('code', '?')})")
181
+
182
+ def main():
183
+ log("🚀 启动中...")
184
+ processed = load_processed()
185
+ token = None
186
+ token_time = 0
187
+
188
+ while True:
189
+ try:
190
+ # 每 30 分钟刷新一次 token
191
+ if not token or time.time() - token_time > 1800:
192
+ token = get_tenant_token()
193
+ token_time = time.time()
194
+ log("🔑 Token 已刷新")
195
+
196
+ chats = get_bot_chats(token)
197
+ for chat_id in chats:
198
+ messages = get_recent_messages(token, chat_id, limit=5)
199
+ for msg in messages:
200
+ msg_id = msg.get("message_id", "")
201
+ if msg_id in processed:
202
+ continue
203
+ processed.add(msg_id)
204
+
205
+ # 检查是否包含图片
206
+ msg_type = msg.get("msg_type", "")
207
+ if msg_type in ("image", "post"):
208
+ process_message(token, chat_id, msg)
209
+
210
+ save_processed(processed)
211
+
212
+ except Exception as e:
213
+ log(f"❌ 错误: {e}")
214
+
215
+ time.sleep(POLL_INTERVAL)
216
+
217
+ if __name__ == "__main__":
218
+ main()