remote-claude 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +15 -0
- package/LICENSE +21 -0
- package/README.md +231 -0
- package/bin/cl +20 -0
- package/bin/cla +20 -0
- package/bin/remote-claude +21 -0
- package/client/client.py +251 -0
- package/lark_client/__init__.py +3 -0
- package/lark_client/capture_output.py +91 -0
- package/lark_client/card_builder.py +1114 -0
- package/lark_client/card_service.py +250 -0
- package/lark_client/config.py +22 -0
- package/lark_client/lark_handler.py +841 -0
- package/lark_client/main.py +306 -0
- package/lark_client/output_cleaner.py +222 -0
- package/lark_client/session_bridge.py +195 -0
- package/lark_client/shared_memory_poller.py +364 -0
- package/lark_client/terminal_buffer.py +215 -0
- package/lark_client/terminal_renderer.py +69 -0
- package/package.json +41 -0
- package/pyproject.toml +14 -0
- package/remote_claude.py +518 -0
- package/scripts/check-env.sh +40 -0
- package/scripts/completion.sh +76 -0
- package/scripts/postinstall.sh +76 -0
- package/server/component_parser.py +1113 -0
- package/server/rich_text_renderer.py +301 -0
- package/server/server.py +801 -0
- package/server/shared_state.py +198 -0
- package/stats/__init__.py +38 -0
- package/stats/collector.py +325 -0
- package/stats/machine.py +47 -0
- package/stats/query.py +151 -0
- package/utils/components.py +165 -0
- package/utils/protocol.py +164 -0
- package/utils/session.py +409 -0
- package/uv.lock +703 -0
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
"""
|
|
2
|
+
飞书消息处理器 - 基于共享内存的推送架构
|
|
3
|
+
|
|
4
|
+
架构:
|
|
5
|
+
Server → .mq 共享内存 → SharedMemoryPoller → 飞书卡片
|
|
6
|
+
SessionBridge 只负责:连接管理 + 输入发送
|
|
7
|
+
|
|
8
|
+
群聊/私聊统一逻辑:以 chat_id 为 key 管理所有 bridge 和会话绑定。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional, Dict, Any, List
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger('LarkHandler')
|
|
20
|
+
|
|
21
|
+
from .session_bridge import SessionBridge
|
|
22
|
+
from .card_service import card_service
|
|
23
|
+
from .card_builder import (
|
|
24
|
+
build_stream_card,
|
|
25
|
+
build_status_card,
|
|
26
|
+
build_help_card,
|
|
27
|
+
build_dir_card,
|
|
28
|
+
build_menu_card,
|
|
29
|
+
build_session_closed_card,
|
|
30
|
+
)
|
|
31
|
+
from .shared_memory_poller import SharedMemoryPoller, CardSlice
|
|
32
|
+
|
|
33
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
34
|
+
from utils.session import list_active_sessions, get_socket_path
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
from stats import track as _track_stats
|
|
38
|
+
except Exception:
|
|
39
|
+
def _track_stats(*args, **kwargs): pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class LarkHandler:
|
|
43
|
+
"""飞书消息处理器(群聊/私聊统一逻辑)"""
|
|
44
|
+
|
|
45
|
+
_CHAT_BINDINGS_FILE = Path("/tmp/remote-claude/lark_chat_bindings.json")
|
|
46
|
+
|
|
47
|
+
def __init__(self):
|
|
48
|
+
# chat_id → SessionBridge(活跃连接)
|
|
49
|
+
self._bridges: Dict[str, SessionBridge] = {}
|
|
50
|
+
# chat_id → session_name(当前连接状态)
|
|
51
|
+
self._chat_sessions: Dict[str, str] = {}
|
|
52
|
+
# 共享内存轮询器
|
|
53
|
+
self._poller = SharedMemoryPoller(card_service)
|
|
54
|
+
# chat_id → session_name 持久化绑定(重启后自动恢复)
|
|
55
|
+
self._chat_bindings: Dict[str, str] = self._load_chat_bindings()
|
|
56
|
+
# chat_id → CardSlice(用户主动断开后保留,供重连时冻结旧卡片)
|
|
57
|
+
self._detached_slices: Dict[str, CardSlice] = {}
|
|
58
|
+
|
|
59
|
+
# ── 持久化绑定 ──────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
def _load_chat_bindings(self) -> Dict[str, str]:
|
|
62
|
+
try:
|
|
63
|
+
if self._CHAT_BINDINGS_FILE.exists():
|
|
64
|
+
return json.loads(self._CHAT_BINDINGS_FILE.read_text())
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
return {}
|
|
68
|
+
|
|
69
|
+
def _save_chat_bindings(self):
|
|
70
|
+
try:
|
|
71
|
+
self._CHAT_BINDINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
self._CHAT_BINDINGS_FILE.write_text(
|
|
73
|
+
json.dumps(self._chat_bindings, ensure_ascii=False)
|
|
74
|
+
)
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.warning(f"保存绑定失败: {e}")
|
|
77
|
+
|
|
78
|
+
def _remove_binding_by_chat(self, chat_id: str):
|
|
79
|
+
self._chat_bindings.pop(chat_id, None)
|
|
80
|
+
self._save_chat_bindings()
|
|
81
|
+
|
|
82
|
+
# ── 统一 attach / detach / on_disconnect ────────────────────────────────
|
|
83
|
+
|
|
84
|
+
async def _attach(self, chat_id: str, session_name: str) -> bool:
|
|
85
|
+
"""统一 attach 逻辑(私聊/群聊共用)"""
|
|
86
|
+
# 断开旧 bridge
|
|
87
|
+
old = self._bridges.pop(chat_id, None)
|
|
88
|
+
if old:
|
|
89
|
+
await old.disconnect()
|
|
90
|
+
self._poller.stop(chat_id)
|
|
91
|
+
self._chat_sessions.pop(chat_id, None)
|
|
92
|
+
self._detached_slices.pop(chat_id, None)
|
|
93
|
+
|
|
94
|
+
def on_disconnect():
|
|
95
|
+
asyncio.create_task(self._on_disconnect(chat_id, session_name))
|
|
96
|
+
|
|
97
|
+
bridge = SessionBridge(session_name, on_disconnect=on_disconnect)
|
|
98
|
+
if await bridge.connect():
|
|
99
|
+
self._bridges[chat_id] = bridge
|
|
100
|
+
self._chat_sessions[chat_id] = session_name
|
|
101
|
+
self._poller.start(chat_id, session_name)
|
|
102
|
+
_track_stats('lark', 'attach', session_name=session_name,
|
|
103
|
+
chat_id=chat_id)
|
|
104
|
+
return True
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
async def _detach(self, chat_id: str):
|
|
108
|
+
"""统一 detach 逻辑(私聊/群聊共用)"""
|
|
109
|
+
bridge = self._bridges.pop(chat_id, None)
|
|
110
|
+
if bridge:
|
|
111
|
+
await bridge.disconnect()
|
|
112
|
+
self._chat_sessions.pop(chat_id, None)
|
|
113
|
+
self._poller.stop(chat_id)
|
|
114
|
+
|
|
115
|
+
async def _on_disconnect(self, chat_id: str, session_name: str):
|
|
116
|
+
"""服务端关闭连接时的统一处理"""
|
|
117
|
+
logger.info(f"会话 '{session_name}' 断线, chat_id={chat_id[:8]}...")
|
|
118
|
+
_track_stats('lark', 'disconnect', session_name=session_name,
|
|
119
|
+
chat_id=chat_id)
|
|
120
|
+
active_slice = self._poller.stop_and_get_active_slice(chat_id)
|
|
121
|
+
self._bridges.pop(chat_id, None)
|
|
122
|
+
self._chat_sessions.pop(chat_id, None)
|
|
123
|
+
self._detached_slices.pop(chat_id, None)
|
|
124
|
+
self._remove_binding_by_chat(chat_id)
|
|
125
|
+
|
|
126
|
+
card = build_session_closed_card(session_name)
|
|
127
|
+
if active_slice:
|
|
128
|
+
try:
|
|
129
|
+
success = await card_service.update_card(
|
|
130
|
+
card_id=active_slice.card_id,
|
|
131
|
+
sequence=active_slice.sequence + 1,
|
|
132
|
+
card_content=card,
|
|
133
|
+
)
|
|
134
|
+
if success:
|
|
135
|
+
return
|
|
136
|
+
except Exception as e:
|
|
137
|
+
logger.warning(f"_on_disconnect 就地更新失败: {e}")
|
|
138
|
+
await card_service.create_and_send_card(chat_id, card)
|
|
139
|
+
|
|
140
|
+
# ── 消息入口 ────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
async def handle_message(self, user_id: str, chat_id: str, text: str,
|
|
143
|
+
chat_type: str = "p2p"):
|
|
144
|
+
"""处理用户消息(群聊/私聊统一路由)"""
|
|
145
|
+
logger.info(f"收到消息: user={user_id[:8]}..., chat={chat_id[:8]}..., type={chat_type}, text={text[:50]}")
|
|
146
|
+
text = text.strip()
|
|
147
|
+
|
|
148
|
+
if text.startswith("/"):
|
|
149
|
+
# /cl 前缀:去掉前缀,转发给 Claude
|
|
150
|
+
if text == "/cl" or text.startswith("/cl "):
|
|
151
|
+
claude_text = text[3:].strip()
|
|
152
|
+
if claude_text:
|
|
153
|
+
await self._forward_to_claude(user_id, chat_id, claude_text)
|
|
154
|
+
_track_stats('lark', 'message',
|
|
155
|
+
session_name=self._chat_sessions.get(chat_id, ''),
|
|
156
|
+
chat_id=chat_id)
|
|
157
|
+
else:
|
|
158
|
+
await self._handle_command(user_id, chat_id, text)
|
|
159
|
+
# else: 普通聊天消息(无 /cl 前缀),不再转发给 Claude
|
|
160
|
+
|
|
161
|
+
async def forward_to_claude(self, user_id: str, chat_id: str, text: str):
|
|
162
|
+
"""卡片输入框直通 Claude(跳过命令路由)"""
|
|
163
|
+
await self._forward_to_claude(user_id, chat_id, text)
|
|
164
|
+
_track_stats('lark', 'message',
|
|
165
|
+
session_name=self._chat_sessions.get(chat_id, ''),
|
|
166
|
+
chat_id=chat_id)
|
|
167
|
+
|
|
168
|
+
async def _handle_command(self, user_id: str, chat_id: str, text: str):
|
|
169
|
+
"""处理命令(群聊/私聊共用同一逻辑)"""
|
|
170
|
+
parts = text.split(maxsplit=1)
|
|
171
|
+
command = parts[0].lower()
|
|
172
|
+
args = parts[1] if len(parts) > 1 else ""
|
|
173
|
+
_track_stats('lark', 'cmd',
|
|
174
|
+
session_name=self._chat_sessions.get(chat_id, ''),
|
|
175
|
+
chat_id=chat_id, detail=command)
|
|
176
|
+
|
|
177
|
+
if command == "/attach":
|
|
178
|
+
await self._cmd_attach(user_id, chat_id, args)
|
|
179
|
+
elif command == "/detach":
|
|
180
|
+
await self._cmd_detach(user_id, chat_id)
|
|
181
|
+
elif command == "/list":
|
|
182
|
+
await self._cmd_list(user_id, chat_id)
|
|
183
|
+
elif command == "/status":
|
|
184
|
+
await self._cmd_status(user_id, chat_id)
|
|
185
|
+
elif command == "/start":
|
|
186
|
+
await self._cmd_start(user_id, chat_id, args)
|
|
187
|
+
elif command == "/kill":
|
|
188
|
+
await self._cmd_kill(user_id, chat_id, args)
|
|
189
|
+
elif command in ("/ls", "/tree"):
|
|
190
|
+
await self._cmd_ls(user_id, chat_id, args, tree=(command == "/tree"))
|
|
191
|
+
elif command == "/new-group":
|
|
192
|
+
await self._cmd_new_group(user_id, chat_id, args)
|
|
193
|
+
elif command == "/help":
|
|
194
|
+
await self._cmd_help(user_id, chat_id)
|
|
195
|
+
elif command == "/menu":
|
|
196
|
+
await self._cmd_menu(user_id, chat_id)
|
|
197
|
+
else:
|
|
198
|
+
await card_service.send_text(chat_id, f"未知命令: {command}\n使用 /help 查看帮助")
|
|
199
|
+
|
|
200
|
+
# ── 命令处理 ─────────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
async def _cmd_attach(self, user_id: str, chat_id: str, args: str,
|
|
203
|
+
message_id: Optional[str] = None):
|
|
204
|
+
"""连接到会话"""
|
|
205
|
+
session_name = args.strip()
|
|
206
|
+
|
|
207
|
+
if not session_name:
|
|
208
|
+
await self._cmd_list(user_id, chat_id, message_id=message_id)
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
sessions = list_active_sessions()
|
|
212
|
+
if not any(s["name"] == session_name for s in sessions):
|
|
213
|
+
await card_service.send_text(
|
|
214
|
+
chat_id, f"会话 '{session_name}' 不存在,使用 /list 查看可用会话"
|
|
215
|
+
)
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
ok = await self._attach(chat_id, session_name)
|
|
219
|
+
if ok:
|
|
220
|
+
self._chat_bindings[chat_id] = session_name
|
|
221
|
+
self._save_chat_bindings()
|
|
222
|
+
if message_id:
|
|
223
|
+
await self._cmd_list(user_id, chat_id, message_id=message_id)
|
|
224
|
+
else:
|
|
225
|
+
await card_service.send_text(chat_id, f"❌ 无法连接到会话 '{session_name}'")
|
|
226
|
+
|
|
227
|
+
async def _cmd_detach(self, user_id: str, chat_id: str,
|
|
228
|
+
message_id: Optional[str] = None):
|
|
229
|
+
"""断开会话"""
|
|
230
|
+
if chat_id not in self._bridges and chat_id not in self._chat_sessions:
|
|
231
|
+
await card_service.send_text(chat_id, "当前未连接到任何会话")
|
|
232
|
+
return
|
|
233
|
+
self._remove_binding_by_chat(chat_id)
|
|
234
|
+
await self._detach(chat_id)
|
|
235
|
+
await self._cmd_menu(user_id, chat_id, message_id=message_id)
|
|
236
|
+
|
|
237
|
+
async def _cmd_list(self, user_id: str, chat_id: str,
|
|
238
|
+
message_id: Optional[str] = None):
|
|
239
|
+
"""列出会话(等价于菜单)"""
|
|
240
|
+
await self._cmd_menu(user_id, chat_id, message_id=message_id)
|
|
241
|
+
|
|
242
|
+
async def _cmd_status(self, user_id: str, chat_id: str):
|
|
243
|
+
"""显示状态"""
|
|
244
|
+
session_name = self._chat_sessions.get(chat_id)
|
|
245
|
+
bridge = self._bridges.get(chat_id)
|
|
246
|
+
if bridge and bridge.running and session_name:
|
|
247
|
+
card = build_status_card(True, session_name)
|
|
248
|
+
else:
|
|
249
|
+
card = build_status_card(False)
|
|
250
|
+
card_id = await card_service.create_card(card)
|
|
251
|
+
if card_id:
|
|
252
|
+
await card_service.send_card(chat_id, card_id)
|
|
253
|
+
|
|
254
|
+
async def _cmd_start(self, user_id: str, chat_id: str, args: str):
|
|
255
|
+
"""启动新会话"""
|
|
256
|
+
parts = args.strip().split(maxsplit=1)
|
|
257
|
+
if not parts:
|
|
258
|
+
await card_service.send_text(
|
|
259
|
+
chat_id,
|
|
260
|
+
"用法: /start <会话名> [工作路径]\n\n"
|
|
261
|
+
"示例:\n"
|
|
262
|
+
" /start mywork ~/dev/myproject\n"
|
|
263
|
+
" /start test ~/dev/myproject"
|
|
264
|
+
)
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
session_name = parts[0]
|
|
268
|
+
work_dir = parts[1] if len(parts) > 1 else None
|
|
269
|
+
|
|
270
|
+
if work_dir:
|
|
271
|
+
work_path = Path(work_dir).expanduser()
|
|
272
|
+
if not work_path.exists():
|
|
273
|
+
await card_service.send_text(chat_id, f"错误: 路径不存在: {work_dir}")
|
|
274
|
+
return
|
|
275
|
+
if not work_path.is_dir():
|
|
276
|
+
await card_service.send_text(chat_id, f"错误: 不是目录: {work_dir}")
|
|
277
|
+
return
|
|
278
|
+
work_dir = str(work_path.absolute())
|
|
279
|
+
|
|
280
|
+
sessions = list_active_sessions()
|
|
281
|
+
if any(s["name"] == session_name for s in sessions):
|
|
282
|
+
await card_service.send_text(
|
|
283
|
+
chat_id,
|
|
284
|
+
f"错误: 会话 '{session_name}' 已存在\n使用 /attach {session_name} 连接"
|
|
285
|
+
)
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
script_dir = Path(__file__).parent.parent.absolute()
|
|
289
|
+
server_script = script_dir / "server" / "server.py"
|
|
290
|
+
cmd = [sys.executable, str(server_script), session_name]
|
|
291
|
+
|
|
292
|
+
logger.info(f"启动会话: {session_name}, 工作目录: {work_dir}, 命令: {cmd}")
|
|
293
|
+
_track_stats('lark', 'cmd_start', session_name=session_name, chat_id=chat_id)
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
import os as _os
|
|
297
|
+
env = _os.environ.copy()
|
|
298
|
+
env.pop("CLAUDECODE", None)
|
|
299
|
+
|
|
300
|
+
subprocess.Popen(
|
|
301
|
+
cmd,
|
|
302
|
+
stdout=subprocess.DEVNULL,
|
|
303
|
+
stderr=subprocess.DEVNULL,
|
|
304
|
+
start_new_session=True,
|
|
305
|
+
cwd=work_dir,
|
|
306
|
+
env=env,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
socket_path = get_socket_path(session_name)
|
|
310
|
+
for _ in range(120):
|
|
311
|
+
await asyncio.sleep(0.1)
|
|
312
|
+
if socket_path.exists():
|
|
313
|
+
break
|
|
314
|
+
else:
|
|
315
|
+
await card_service.send_text(chat_id, "错误: 会话启动超时")
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
ok = await self._attach(chat_id, session_name)
|
|
319
|
+
if ok:
|
|
320
|
+
self._chat_bindings[chat_id] = session_name
|
|
321
|
+
self._save_chat_bindings()
|
|
322
|
+
else:
|
|
323
|
+
await card_service.send_text(
|
|
324
|
+
chat_id,
|
|
325
|
+
f"会话已启动但连接失败\n使用 /attach {session_name} 重试"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
except Exception as e:
|
|
329
|
+
logger.error(f"启动会话失败: {e}")
|
|
330
|
+
await card_service.send_text(chat_id, f"错误: 启动失败 - {e}")
|
|
331
|
+
|
|
332
|
+
async def _cmd_start_and_new_group(self, user_id: str, chat_id: str,
|
|
333
|
+
session_name: str, path: str):
|
|
334
|
+
"""在指定目录启动会话并创建专属群聊"""
|
|
335
|
+
sessions = list_active_sessions()
|
|
336
|
+
if any(s["name"] == session_name for s in sessions):
|
|
337
|
+
# 会话已存在,直接创建群聊
|
|
338
|
+
await self._cmd_new_group(user_id, chat_id, session_name)
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
work_path = Path(path).expanduser()
|
|
342
|
+
if not work_path.is_dir():
|
|
343
|
+
await card_service.send_text(chat_id, f"错误: 路径无效: {path}")
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
work_dir = str(work_path.absolute())
|
|
347
|
+
script_dir = Path(__file__).parent.parent.absolute()
|
|
348
|
+
server_script = script_dir / "server" / "server.py"
|
|
349
|
+
cmd = [sys.executable, str(server_script), session_name]
|
|
350
|
+
|
|
351
|
+
try:
|
|
352
|
+
import os as _os
|
|
353
|
+
env = _os.environ.copy()
|
|
354
|
+
env.pop("CLAUDECODE", None)
|
|
355
|
+
subprocess.Popen(
|
|
356
|
+
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
357
|
+
start_new_session=True, cwd=work_dir, env=env,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
socket_path = get_socket_path(session_name)
|
|
361
|
+
for _ in range(120):
|
|
362
|
+
await asyncio.sleep(0.1)
|
|
363
|
+
if socket_path.exists():
|
|
364
|
+
break
|
|
365
|
+
else:
|
|
366
|
+
await card_service.send_text(chat_id, "错误: 会话启动超时")
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
await self._cmd_new_group(user_id, chat_id, session_name)
|
|
370
|
+
|
|
371
|
+
except Exception as e:
|
|
372
|
+
logger.error(f"启动并创建群聊失败: {e}")
|
|
373
|
+
await card_service.send_text(chat_id, f"操作失败:{e}")
|
|
374
|
+
|
|
375
|
+
async def _cmd_kill(self, user_id: str, chat_id: str, args: str):
|
|
376
|
+
"""终止会话"""
|
|
377
|
+
from utils.session import cleanup_session, tmux_session_exists, tmux_kill_session
|
|
378
|
+
|
|
379
|
+
session_name = args.strip()
|
|
380
|
+
if not session_name:
|
|
381
|
+
await card_service.send_text(chat_id, "用法: /kill <会话名>")
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
sessions = list_active_sessions()
|
|
385
|
+
if not any(s["name"] == session_name for s in sessions):
|
|
386
|
+
await card_service.send_text(chat_id, f"错误: 会话 '{session_name}' 不存在")
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
# 断开所有连接到此会话的 chat
|
|
390
|
+
for cid, sname in list(self._chat_sessions.items()):
|
|
391
|
+
if sname == session_name:
|
|
392
|
+
await self._detach(cid)
|
|
393
|
+
self._remove_binding_by_chat(cid)
|
|
394
|
+
|
|
395
|
+
if tmux_session_exists(session_name):
|
|
396
|
+
tmux_kill_session(session_name)
|
|
397
|
+
cleanup_session(session_name)
|
|
398
|
+
|
|
399
|
+
await card_service.send_text(chat_id, f"✅ 会话 '{session_name}' 已终止")
|
|
400
|
+
|
|
401
|
+
async def _handle_list_detach(self, user_id: str, chat_id: str,
|
|
402
|
+
message_id: Optional[str] = None):
|
|
403
|
+
"""会话列表卡片中断开连接,就地刷新列表"""
|
|
404
|
+
self._remove_binding_by_chat(chat_id)
|
|
405
|
+
await self._detach(chat_id)
|
|
406
|
+
await self._cmd_list(user_id, chat_id, message_id=message_id)
|
|
407
|
+
|
|
408
|
+
async def _handle_stream_detach(self, user_id: str, chat_id: str,
|
|
409
|
+
session_name: str, message_id: Optional[str] = None):
|
|
410
|
+
"""流式卡片中断开连接,就地更新卡片为已断开状态"""
|
|
411
|
+
# 停止轮询并获取活跃 CardSlice(原子操作)
|
|
412
|
+
active_slice = self._poller.stop_and_get_active_slice(chat_id)
|
|
413
|
+
|
|
414
|
+
# 读取最后快照的 blocks
|
|
415
|
+
blocks = []
|
|
416
|
+
try:
|
|
417
|
+
import sys as _sys
|
|
418
|
+
_sys.path.insert(0, str(Path(__file__).parent.parent / "server"))
|
|
419
|
+
from shared_state import SharedStateReader, get_mq_path
|
|
420
|
+
mq_path = get_mq_path(session_name)
|
|
421
|
+
if mq_path.exists():
|
|
422
|
+
reader = SharedStateReader(session_name)
|
|
423
|
+
state = reader.read()
|
|
424
|
+
reader.close()
|
|
425
|
+
blocks = state.get("blocks", [])
|
|
426
|
+
except Exception:
|
|
427
|
+
pass
|
|
428
|
+
|
|
429
|
+
self._remove_binding_by_chat(chat_id)
|
|
430
|
+
# _detach 中 _poller.stop() 幂等(已调用 stop_and_get_active_slice)
|
|
431
|
+
await self._detach(chat_id)
|
|
432
|
+
|
|
433
|
+
blocks_slice = blocks[active_slice.start_idx:] if active_slice else blocks
|
|
434
|
+
card = build_stream_card(blocks_slice, disconnected=True, session_name=session_name)
|
|
435
|
+
|
|
436
|
+
updated = False
|
|
437
|
+
if active_slice:
|
|
438
|
+
try:
|
|
439
|
+
success = await card_service.update_card(
|
|
440
|
+
card_id=active_slice.card_id,
|
|
441
|
+
sequence=active_slice.sequence + 1,
|
|
442
|
+
card_content=card,
|
|
443
|
+
)
|
|
444
|
+
if success:
|
|
445
|
+
active_slice.sequence += 1
|
|
446
|
+
self._detached_slices[chat_id] = active_slice
|
|
447
|
+
updated = True
|
|
448
|
+
except Exception as e:
|
|
449
|
+
logger.warning(f"_handle_stream_detach 就地更新失败: {e}")
|
|
450
|
+
|
|
451
|
+
if not updated:
|
|
452
|
+
await self._send_or_update_card(chat_id, card, message_id)
|
|
453
|
+
|
|
454
|
+
async def _handle_stream_reconnect(self, user_id: str, chat_id: str,
|
|
455
|
+
session_name: str, message_id: Optional[str] = None):
|
|
456
|
+
"""流式卡片中重新连接:冻结旧断开卡片 → 重新 attach"""
|
|
457
|
+
# 冻结旧断开卡片
|
|
458
|
+
old_slice = self._detached_slices.pop(chat_id, None)
|
|
459
|
+
if old_slice:
|
|
460
|
+
try:
|
|
461
|
+
frozen_card = build_stream_card([], is_frozen=True, session_name=session_name)
|
|
462
|
+
await card_service.update_card(
|
|
463
|
+
card_id=old_slice.card_id,
|
|
464
|
+
sequence=old_slice.sequence + 1,
|
|
465
|
+
card_content=frozen_card,
|
|
466
|
+
)
|
|
467
|
+
except Exception as e:
|
|
468
|
+
logger.warning(f"_handle_stream_reconnect 冻结旧卡片失败: {e}")
|
|
469
|
+
elif message_id:
|
|
470
|
+
try:
|
|
471
|
+
frozen_card = build_stream_card([], is_frozen=True, session_name=session_name)
|
|
472
|
+
await card_service.update_card_by_message_id(message_id, frozen_card)
|
|
473
|
+
except Exception as e:
|
|
474
|
+
logger.warning(f"_handle_stream_reconnect 按 message_id 冻结失败: {e}")
|
|
475
|
+
|
|
476
|
+
await self._cmd_attach(user_id, chat_id, session_name)
|
|
477
|
+
|
|
478
|
+
async def _cmd_help(self, user_id: str, chat_id: str,
|
|
479
|
+
message_id: Optional[str] = None):
|
|
480
|
+
"""显示帮助"""
|
|
481
|
+
card = build_help_card()
|
|
482
|
+
await self._send_or_update_card(chat_id, card, message_id)
|
|
483
|
+
|
|
484
|
+
async def _cmd_menu(self, user_id: str, chat_id: str,
|
|
485
|
+
message_id: Optional[str] = None):
|
|
486
|
+
"""显示快捷操作菜单(内嵌会话列表)"""
|
|
487
|
+
sessions = list_active_sessions()
|
|
488
|
+
current = self._chat_sessions.get(chat_id)
|
|
489
|
+
session_groups = {sname: cid for cid, sname in self._chat_bindings.items() if cid.startswith("oc_")}
|
|
490
|
+
card = build_menu_card(sessions, current_session=current, session_groups=session_groups)
|
|
491
|
+
await self._send_or_update_card(chat_id, card, message_id)
|
|
492
|
+
|
|
493
|
+
async def _cmd_ls(self, user_id: str, chat_id: str, args: str,
|
|
494
|
+
tree: bool = False, message_id: Optional[str] = None):
|
|
495
|
+
"""查看目录文件结构"""
|
|
496
|
+
all_sessions = list_active_sessions()
|
|
497
|
+
sessions_info = []
|
|
498
|
+
for s in all_sessions:
|
|
499
|
+
pid = s.get("pid")
|
|
500
|
+
cwd = self._get_pid_cwd(pid) if pid else None
|
|
501
|
+
sessions_info.append({"name": s["name"], "cwd": cwd or ""})
|
|
502
|
+
|
|
503
|
+
bound_session = self._chat_sessions.get(chat_id)
|
|
504
|
+
if bound_session:
|
|
505
|
+
pid = next((s.get("pid") for s in all_sessions if s["name"] == bound_session), None)
|
|
506
|
+
session_cwd = self._get_pid_cwd(pid) if pid else None
|
|
507
|
+
root = Path(session_cwd) if session_cwd else Path.home()
|
|
508
|
+
else:
|
|
509
|
+
root = Path.home()
|
|
510
|
+
|
|
511
|
+
target_arg = args.strip()
|
|
512
|
+
if target_arg:
|
|
513
|
+
target = Path(target_arg).expanduser()
|
|
514
|
+
if not target.is_absolute():
|
|
515
|
+
target = root / target
|
|
516
|
+
else:
|
|
517
|
+
target = root
|
|
518
|
+
|
|
519
|
+
target = target.resolve()
|
|
520
|
+
if not target.exists():
|
|
521
|
+
await card_service.send_text(chat_id, f"路径不存在:{target}")
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
try:
|
|
525
|
+
if tree:
|
|
526
|
+
entries = self._collect_tree_entries(target)
|
|
527
|
+
else:
|
|
528
|
+
entries = self._collect_ls_entries(target)
|
|
529
|
+
except Exception as e:
|
|
530
|
+
await card_service.send_text(chat_id, f"读取目录失败:{e}")
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
session_groups = {sname: cid for cid, sname in self._chat_bindings.items() if cid.startswith("oc_")}
|
|
534
|
+
card = build_dir_card(target, entries, sessions_info, tree=tree, session_groups=session_groups)
|
|
535
|
+
await self._send_or_update_card(chat_id, card, message_id)
|
|
536
|
+
|
|
537
|
+
async def _cmd_new_group(self, user_id: str, chat_id: str, args: str):
|
|
538
|
+
"""创建专属群聊并绑定 Claude 会话"""
|
|
539
|
+
session_name = args.strip()
|
|
540
|
+
if not session_name:
|
|
541
|
+
await card_service.send_text(chat_id, "用法:/new-group <会话名>\n示例:/new-group myapp")
|
|
542
|
+
return
|
|
543
|
+
|
|
544
|
+
sessions = list_active_sessions()
|
|
545
|
+
if not any(s["name"] == session_name for s in sessions):
|
|
546
|
+
await card_service.send_text(chat_id, f"会话 '{session_name}' 不存在,请先 /start 启动")
|
|
547
|
+
return
|
|
548
|
+
|
|
549
|
+
session = next((s for s in sessions if s["name"] == session_name), None)
|
|
550
|
+
pid = session.get("pid") if session else None
|
|
551
|
+
cwd = self._get_pid_cwd(pid) if pid else None
|
|
552
|
+
dir_label = cwd.rstrip("/").rsplit("/", 1)[-1] if cwd else session_name
|
|
553
|
+
|
|
554
|
+
import lark_oapi as lark
|
|
555
|
+
from . import config
|
|
556
|
+
try:
|
|
557
|
+
import json as _json
|
|
558
|
+
import urllib.request
|
|
559
|
+
req_body = {
|
|
560
|
+
"name": f"【{dir_label}】{config.BOT_NAME}",
|
|
561
|
+
"description": f"Remote Claude 专属群 - 会话 {session_name}",
|
|
562
|
+
"user_id_list": [user_id],
|
|
563
|
+
}
|
|
564
|
+
token_resp = urllib.request.urlopen(
|
|
565
|
+
urllib.request.Request(
|
|
566
|
+
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
|
|
567
|
+
data=_json.dumps({"app_id": config.FEISHU_APP_ID, "app_secret": config.FEISHU_APP_SECRET}).encode(),
|
|
568
|
+
headers={"Content-Type": "application/json"},
|
|
569
|
+
method="POST"
|
|
570
|
+
), timeout=10
|
|
571
|
+
)
|
|
572
|
+
token_data = _json.loads(token_resp.read())
|
|
573
|
+
token = token_data["tenant_access_token"]
|
|
574
|
+
|
|
575
|
+
create_resp = urllib.request.urlopen(
|
|
576
|
+
urllib.request.Request(
|
|
577
|
+
"https://open.feishu.cn/open-apis/im/v1/chats?user_id_type=open_id",
|
|
578
|
+
data=_json.dumps(req_body).encode(),
|
|
579
|
+
headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"},
|
|
580
|
+
method="POST"
|
|
581
|
+
), timeout=10
|
|
582
|
+
)
|
|
583
|
+
create_data = _json.loads(create_resp.read())
|
|
584
|
+
|
|
585
|
+
if create_data.get("code") != 0:
|
|
586
|
+
await card_service.send_text(chat_id, f"创建群失败:{create_data.get('msg')}")
|
|
587
|
+
return
|
|
588
|
+
|
|
589
|
+
group_chat_id = create_data["data"]["chat_id"]
|
|
590
|
+
self._chat_bindings[group_chat_id] = session_name
|
|
591
|
+
self._save_chat_bindings()
|
|
592
|
+
# 立即 attach,让新群即刻开始接收 Claude 输出
|
|
593
|
+
await self._attach(group_chat_id, session_name)
|
|
594
|
+
|
|
595
|
+
await card_service.send_text(
|
|
596
|
+
chat_id,
|
|
597
|
+
f"✅ 已创建专属群「【{dir_label}】{config.BOT_NAME}」并已连接\n"
|
|
598
|
+
f"在群内直接发消息即可与 Claude 交互"
|
|
599
|
+
)
|
|
600
|
+
except Exception as e:
|
|
601
|
+
logger.error(f"创建群失败: {e}")
|
|
602
|
+
await card_service.send_text(chat_id, f"创建群失败:{e}")
|
|
603
|
+
|
|
604
|
+
async def _cmd_disband_group(self, user_id: str, chat_id: str, session_name: str,
|
|
605
|
+
message_id: Optional[str] = None):
|
|
606
|
+
"""解散与指定会话绑定的专属群聊"""
|
|
607
|
+
group_chat_id = next(
|
|
608
|
+
(cid for cid, sname in self._chat_bindings.items() if sname == session_name and cid.startswith("oc_")),
|
|
609
|
+
None
|
|
610
|
+
)
|
|
611
|
+
if not group_chat_id:
|
|
612
|
+
await card_service.send_text(chat_id, f"会话 '{session_name}' 没有绑定群聊")
|
|
613
|
+
return
|
|
614
|
+
|
|
615
|
+
import json as _json
|
|
616
|
+
import urllib.request
|
|
617
|
+
from . import config
|
|
618
|
+
try:
|
|
619
|
+
token_resp = urllib.request.urlopen(
|
|
620
|
+
urllib.request.Request(
|
|
621
|
+
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
|
|
622
|
+
data=_json.dumps({"app_id": config.FEISHU_APP_ID, "app_secret": config.FEISHU_APP_SECRET}).encode(),
|
|
623
|
+
headers={"Content-Type": "application/json"},
|
|
624
|
+
method="POST"
|
|
625
|
+
), timeout=10
|
|
626
|
+
)
|
|
627
|
+
token = _json.loads(token_resp.read())["tenant_access_token"]
|
|
628
|
+
|
|
629
|
+
disband_resp = urllib.request.urlopen(
|
|
630
|
+
urllib.request.Request(
|
|
631
|
+
f"https://open.feishu.cn/open-apis/im/v1/chats/{group_chat_id}",
|
|
632
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
633
|
+
method="DELETE"
|
|
634
|
+
), timeout=10
|
|
635
|
+
)
|
|
636
|
+
disband_data = _json.loads(disband_resp.read())
|
|
637
|
+
if disband_data.get("code") != 0:
|
|
638
|
+
await card_service.send_text(chat_id, f"解散群失败:{disband_data.get('msg')}")
|
|
639
|
+
return
|
|
640
|
+
|
|
641
|
+
self._remove_binding_by_chat(group_chat_id)
|
|
642
|
+
await self._detach(group_chat_id)
|
|
643
|
+
await self._cmd_list(user_id, chat_id, message_id=message_id)
|
|
644
|
+
except Exception as e:
|
|
645
|
+
logger.error(f"解散群失败: {e}")
|
|
646
|
+
await card_service.send_text(chat_id, f"解散群失败:{e}")
|
|
647
|
+
|
|
648
|
+
# ── 消息转发 ─────────────────────────────────────────────────────────────
|
|
649
|
+
|
|
650
|
+
async def _forward_to_claude(self, user_id: str, chat_id: str, text: str):
|
|
651
|
+
"""转发消息给 Claude(输出由 SharedMemoryPoller 自动推卡片)"""
|
|
652
|
+
bridge = self._bridges.get(chat_id)
|
|
653
|
+
|
|
654
|
+
if not bridge or not bridge.running:
|
|
655
|
+
# 尝试从持久化绑定自动恢复
|
|
656
|
+
saved_session = self._chat_bindings.get(chat_id)
|
|
657
|
+
if saved_session:
|
|
658
|
+
logger.info(f"自动恢复绑定: chat_id={chat_id[:8]}..., session={saved_session}")
|
|
659
|
+
ok = await self._attach(chat_id, saved_session)
|
|
660
|
+
if not ok:
|
|
661
|
+
self._remove_binding_by_chat(chat_id)
|
|
662
|
+
await card_service.send_text(
|
|
663
|
+
chat_id, f"会话 '{saved_session}' 已不存在,请重新 /attach"
|
|
664
|
+
)
|
|
665
|
+
return
|
|
666
|
+
bridge = self._bridges.get(chat_id)
|
|
667
|
+
else:
|
|
668
|
+
await card_service.send_text(
|
|
669
|
+
chat_id, "未连接到任何会话,请先使用 /attach <会话名> 连接"
|
|
670
|
+
)
|
|
671
|
+
return
|
|
672
|
+
|
|
673
|
+
if not bridge:
|
|
674
|
+
return
|
|
675
|
+
|
|
676
|
+
success = await bridge.send_input(text)
|
|
677
|
+
if success:
|
|
678
|
+
self._poller.kick(chat_id)
|
|
679
|
+
else:
|
|
680
|
+
await card_service.send_text(chat_id, "发送失败")
|
|
681
|
+
|
|
682
|
+
# ── 选项处理 ─────────────────────────────────────────────────────────────
|
|
683
|
+
|
|
684
|
+
async def handle_option_select(self, user_id: str, chat_id: str, option_value: str, option_total: int = 0):
|
|
685
|
+
"""处理用户选择的选项(按钮点击)
|
|
686
|
+
|
|
687
|
+
最后一个选项特殊处理:Claude CLI 的光标选择模式中,最后一个选项
|
|
688
|
+
直接发数字键无效。改为先发倒数第二项的数字跳转,再发 ↓ 移到最后一项,
|
|
689
|
+
最后发 Enter 确认。
|
|
690
|
+
"""
|
|
691
|
+
logger.info(f"处理选项选择: user={user_id[:8]}..., option={option_value}, total={option_total}")
|
|
692
|
+
_track_stats('lark', 'option_select',
|
|
693
|
+
session_name=self._chat_sessions.get(chat_id, ''),
|
|
694
|
+
chat_id=chat_id, detail=option_value)
|
|
695
|
+
|
|
696
|
+
bridge = self._bridges.get(chat_id)
|
|
697
|
+
if not bridge or not bridge.running:
|
|
698
|
+
await card_service.send_text(chat_id, "未连接到任何会话,请先使用 /attach <会话名> 连接")
|
|
699
|
+
return
|
|
700
|
+
|
|
701
|
+
key_mapping = {
|
|
702
|
+
"yes": "y",
|
|
703
|
+
"no": "n",
|
|
704
|
+
"allow_once": "y",
|
|
705
|
+
"allow_always": "a",
|
|
706
|
+
"deny": "n",
|
|
707
|
+
}
|
|
708
|
+
key_to_send = key_mapping.get(option_value.lower())
|
|
709
|
+
|
|
710
|
+
if key_to_send:
|
|
711
|
+
# 固定映射的选项(permission 类型)
|
|
712
|
+
logger.info(f"发送按键到 Claude: {key_to_send}")
|
|
713
|
+
success = await bridge.send_key(key_to_send)
|
|
714
|
+
elif option_total > 1 and option_value == str(option_total):
|
|
715
|
+
# 最后一个选项:发 (N-1) 次 ↓ → Enter
|
|
716
|
+
import asyncio
|
|
717
|
+
steps = option_total - 1
|
|
718
|
+
logger.info(f"最后一个选项,发送: {steps}次↓ → Enter")
|
|
719
|
+
for _ in range(steps):
|
|
720
|
+
await bridge.send_raw(b"\x1b[B") # ↓ 箭头
|
|
721
|
+
await asyncio.sleep(0.05)
|
|
722
|
+
success = await bridge.send_raw(b"\r")
|
|
723
|
+
else:
|
|
724
|
+
# 普通数字选项
|
|
725
|
+
logger.info(f"发送按键到 Claude: {option_value}")
|
|
726
|
+
success = await bridge.send_key(option_value)
|
|
727
|
+
|
|
728
|
+
if success:
|
|
729
|
+
self._poller.kick(chat_id)
|
|
730
|
+
else:
|
|
731
|
+
await card_service.send_text(chat_id, "发送选择失败")
|
|
732
|
+
|
|
733
|
+
# ── 快捷键发送 ─────────────────────────────────────────────────────────────
|
|
734
|
+
|
|
735
|
+
async def send_raw_key(self, user_id: str, chat_id: str, key_name: str):
|
|
736
|
+
"""发送原始控制键到 Claude CLI"""
|
|
737
|
+
_track_stats('lark', 'raw_key',
|
|
738
|
+
session_name=self._chat_sessions.get(chat_id, ''),
|
|
739
|
+
chat_id=chat_id, detail=key_name)
|
|
740
|
+
KEY_MAP = {
|
|
741
|
+
"up": b"\x1b[A", # ↑ 上箭头
|
|
742
|
+
"down": b"\x1b[B", # ↓ 下箭头
|
|
743
|
+
"enter": b"\r", # Enter
|
|
744
|
+
"ctrl_o": b"\x0f", # Ctrl+O
|
|
745
|
+
"shift_tab": b"\x1b[Z", # Shift+Tab
|
|
746
|
+
"esc": b"\x1b", # ESC
|
|
747
|
+
}
|
|
748
|
+
raw = KEY_MAP.get(key_name)
|
|
749
|
+
if not raw:
|
|
750
|
+
logger.warning(f"未知快捷键: {key_name}")
|
|
751
|
+
return
|
|
752
|
+
|
|
753
|
+
bridge = self._bridges.get(chat_id)
|
|
754
|
+
if not bridge or not bridge.running:
|
|
755
|
+
logger.warning(f"send_raw_key: chat_id={chat_id[:8]}... 未连接会话")
|
|
756
|
+
return
|
|
757
|
+
|
|
758
|
+
success = await bridge.send_raw(raw)
|
|
759
|
+
if success:
|
|
760
|
+
logger.info(f"已发送快捷键 {key_name} 到 Claude")
|
|
761
|
+
self._poller.kick(chat_id)
|
|
762
|
+
else:
|
|
763
|
+
logger.warning(f"发送快捷键 {key_name} 失败")
|
|
764
|
+
|
|
765
|
+
# ── 辅助方法 ─────────────────────────────────────────────────────────────
|
|
766
|
+
|
|
767
|
+
async def _send_or_update_card(
|
|
768
|
+
self, chat_id: str, card: dict, message_id: Optional[str] = None
|
|
769
|
+
):
|
|
770
|
+
"""有 message_id 时就地更新原卡片,否则发新消息;更新失败时降级为发新卡片"""
|
|
771
|
+
if message_id:
|
|
772
|
+
success = await card_service.update_card_by_message_id(message_id, card)
|
|
773
|
+
if success:
|
|
774
|
+
return
|
|
775
|
+
await card_service.create_and_send_card(chat_id, card)
|
|
776
|
+
|
|
777
|
+
@staticmethod
|
|
778
|
+
def _collect_ls_entries(target) -> list:
|
|
779
|
+
"""获取一级目录内容(隐藏文件除外,目录优先)"""
|
|
780
|
+
entries = []
|
|
781
|
+
try:
|
|
782
|
+
items = sorted(target.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
|
|
783
|
+
for item in items:
|
|
784
|
+
if item.name.startswith('.'):
|
|
785
|
+
continue
|
|
786
|
+
entries.append({
|
|
787
|
+
"name": item.name,
|
|
788
|
+
"full_path": str(item),
|
|
789
|
+
"is_dir": item.is_dir(),
|
|
790
|
+
"depth": 0,
|
|
791
|
+
})
|
|
792
|
+
except PermissionError:
|
|
793
|
+
pass
|
|
794
|
+
return entries[:30]
|
|
795
|
+
|
|
796
|
+
@staticmethod
|
|
797
|
+
def _collect_tree_entries(target, max_depth: int = 2, max_items: int = 60) -> list:
|
|
798
|
+
"""获取树状目录内容"""
|
|
799
|
+
entries = []
|
|
800
|
+
|
|
801
|
+
def _walk(path, depth: int):
|
|
802
|
+
if depth > max_depth or len(entries) >= max_items:
|
|
803
|
+
return
|
|
804
|
+
try:
|
|
805
|
+
for item in sorted(path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
|
|
806
|
+
if len(entries) >= max_items:
|
|
807
|
+
break
|
|
808
|
+
if item.name.startswith('.'):
|
|
809
|
+
continue
|
|
810
|
+
entries.append({
|
|
811
|
+
"name": item.name,
|
|
812
|
+
"full_path": str(item),
|
|
813
|
+
"is_dir": item.is_dir(),
|
|
814
|
+
"depth": depth,
|
|
815
|
+
})
|
|
816
|
+
if item.is_dir() and depth < max_depth:
|
|
817
|
+
_walk(item, depth + 1)
|
|
818
|
+
except PermissionError:
|
|
819
|
+
pass
|
|
820
|
+
|
|
821
|
+
_walk(target, 0)
|
|
822
|
+
return entries
|
|
823
|
+
|
|
824
|
+
@staticmethod
|
|
825
|
+
def _get_pid_cwd(pid: int) -> Optional[str]:
|
|
826
|
+
"""获取进程的工作目录(macOS/Linux)"""
|
|
827
|
+
try:
|
|
828
|
+
result = subprocess.run(
|
|
829
|
+
["lsof", "-p", str(pid), "-a", "-d", "cwd", "-F", "n"],
|
|
830
|
+
capture_output=True, text=True, timeout=5
|
|
831
|
+
)
|
|
832
|
+
for line in result.stdout.splitlines():
|
|
833
|
+
if line.startswith("n"):
|
|
834
|
+
return line[1:].strip()
|
|
835
|
+
except Exception:
|
|
836
|
+
pass
|
|
837
|
+
return None
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
# 全局处理器实例
|
|
841
|
+
handler = LarkHandler()
|