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.
@@ -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()