remote-claude 1.0.2 → 1.0.4

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.
@@ -11,8 +11,11 @@
11
11
  import asyncio
12
12
  import json
13
13
  import logging
14
+ import os as _os
14
15
  import subprocess
15
16
  import sys
17
+ import time
18
+ from datetime import datetime as _datetime
16
19
  from pathlib import Path
17
20
  from typing import Optional, Dict, Any, List
18
21
 
@@ -30,7 +33,23 @@ from .card_builder import (
30
33
  from .shared_memory_poller import SharedMemoryPoller, CardSlice
31
34
 
32
35
  sys.path.insert(0, str(Path(__file__).parent.parent))
33
- from utils.session import list_active_sessions, get_socket_path, get_chat_bindings_file, ensure_user_data_dir
36
+ from utils.session import list_active_sessions, get_socket_path, get_chat_bindings_file, ensure_user_data_dir, USER_DATA_DIR
37
+
38
+
39
+ def _read_log_since(since: '_datetime', log_path: 'Path') -> str:
40
+ """读取 startup.log 中 since 时间点之后的日志行"""
41
+ if not log_path.exists():
42
+ return ""
43
+ lines = []
44
+ for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines():
45
+ try:
46
+ ts = _datetime.strptime(line[:23], "%Y-%m-%d %H:%M:%S.%f")
47
+ if ts >= since:
48
+ lines.append(line)
49
+ except ValueError:
50
+ if lines:
51
+ lines.append(line)
52
+ return "\n".join(lines)
34
53
 
35
54
  try:
36
55
  from stats import track as _track_stats
@@ -66,6 +85,8 @@ class LarkHandler:
66
85
  self._group_chat_ids: set = self._load_group_chat_ids()
67
86
  # chat_id → CardSlice(用户主动断开后保留,供重连时冻结旧卡片)
68
87
  self._detached_slices: Dict[str, CardSlice] = {}
88
+ # 正在启动中的会话名集合(防止并发点击触发竞态)
89
+ self._starting_sessions: set = set()
69
90
 
70
91
  # ── 持久化绑定 ──────────────────────────────────────────────────────────
71
92
 
@@ -168,6 +189,9 @@ class LarkHandler:
168
189
  if active_slice:
169
190
  await self._update_card_disconnected(chat_id, session_name, active_slice)
170
191
 
192
+ # 会话退出时自动解散绑定到该会话的所有专属群聊
193
+ await self._disband_groups_for_session(session_name, source="disconnect")
194
+
171
195
  # ── 消息入口 ────────────────────────────────────────────────────────────
172
196
 
173
197
  async def handle_message(self, user_id: str, chat_id: str, text: str,
@@ -225,6 +249,8 @@ class LarkHandler:
225
249
  await self._cmd_help(user_id, chat_id)
226
250
  elif command == "/menu":
227
251
  await self._cmd_menu(user_id, chat_id)
252
+ elif command == "/press":
253
+ await self._cmd_press(user_id, chat_id, args)
228
254
  else:
229
255
  await card_service.send_text(chat_id, f"未知命令: {command}\n使用 /help 查看帮助")
230
256
 
@@ -282,7 +308,7 @@ class LarkHandler:
282
308
  if card_id:
283
309
  await card_service.send_card(chat_id, card_id)
284
310
 
285
- async def _cmd_start(self, user_id: str, chat_id: str, args: str):
311
+ async def _cmd_start(self, user_id: str, chat_id: str, args: str, cli_type: str = "claude"):
286
312
  """启动新会话"""
287
313
  parts = args.strip().split(maxsplit=1)
288
314
  if not parts:
@@ -316,47 +342,41 @@ class LarkHandler:
316
342
  )
317
343
  return
318
344
 
345
+ if session_name in self._starting_sessions:
346
+ await card_service.send_text(chat_id, f"会话 '{session_name}' 正在启动中,请稍候")
347
+ return
348
+ self._starting_sessions.add(session_name)
349
+
319
350
  script_dir = Path(__file__).parent.parent.absolute()
320
351
  server_script = script_dir / "server" / "server.py"
321
- cmd = [sys.executable, str(server_script), session_name]
352
+ cmd = ["uv", "run", "--project", str(script_dir), "python3", str(server_script), session_name]
353
+ if cli_type == "codex":
354
+ cmd += ["--cli-type", "codex"]
322
355
  if self._poller.get_bypass_enabled():
323
- cmd += ["--", "--dangerously-skip-permissions", "--permission-mode=dontAsk"]
356
+ if cli_type == "codex":
357
+ cmd += ["--", "--dangerously-bypass-approvals-and-sandbox"]
358
+ else:
359
+ cmd += ["--", "--dangerously-skip-permissions", "--permission-mode=dontAsk"]
324
360
 
325
- logger.info(f"启动会话: {session_name}, 工作目录: {work_dir}, 命令: {' '.join(cmd)}")
361
+ logger.info(f"启动会话: {session_name}, 工作目录: {work_dir}, cli_type: {cli_type}, 命令: {' '.join(cmd)}")
326
362
  _track_stats('lark', 'cmd_start', session_name=session_name, chat_id=chat_id)
327
363
 
328
364
  try:
329
- import os as _os
330
- from datetime import datetime as _datetime
331
365
  env = _os.environ.copy()
332
366
  env.pop("CLAUDECODE", None)
333
367
 
334
- from utils.session import USER_DATA_DIR
335
368
  log_path = USER_DATA_DIR / "startup.log"
336
369
  start_time = _datetime.now()
337
370
 
338
- proc = subprocess.Popen(
339
- cmd,
340
- stdout=subprocess.DEVNULL,
341
- stderr=subprocess.DEVNULL,
342
- start_new_session=True,
343
- cwd=work_dir,
344
- env=env,
345
- )
346
-
347
- def _read_log_since(since):
348
- if not log_path.exists():
349
- return ""
350
- lines = []
351
- for line in log_path.read_text(encoding="utf-8").splitlines():
352
- try:
353
- ts = _datetime.strptime(line[:23], "%Y-%m-%d %H:%M:%S.%f")
354
- if ts >= since:
355
- lines.append(line)
356
- except ValueError:
357
- if lines:
358
- lines.append(line)
359
- return "\n".join(lines)
371
+ with open(log_path, 'a') as stderr_fd:
372
+ proc = subprocess.Popen(
373
+ cmd,
374
+ stdout=subprocess.DEVNULL,
375
+ stderr=stderr_fd,
376
+ start_new_session=True,
377
+ cwd=work_dir,
378
+ env=env,
379
+ )
360
380
 
361
381
  socket_path = get_socket_path(session_name)
362
382
  for i in range(120):
@@ -367,13 +387,13 @@ class LarkHandler:
367
387
  elapsed = (i + 1) // 10
368
388
  rc = proc.poll()
369
389
  if rc is not None:
370
- log_content = _read_log_since(start_time)
390
+ log_content = _read_log_since(start_time, log_path)
371
391
  logger.warning(f"会话启动失败: server 进程已退出 (exitcode={rc}, elapsed={elapsed}s)\n{log_content}")
372
392
  await card_service.send_text(chat_id, f"错误: Server 进程意外退出 (code={rc})\n\n{log_content}")
373
393
  return
374
394
  logger.info(f"等待 server socket... ({elapsed}s)")
375
395
  else:
376
- log_content = _read_log_since(start_time)
396
+ log_content = _read_log_since(start_time, log_path)
377
397
  logger.error(f"会话启动超时 (12s), session={session_name}\n{log_content}")
378
398
  await card_service.send_text(chat_id, f"错误: 会话启动超时 (12s)\n\n{log_content}")
379
399
  return
@@ -391,44 +411,66 @@ class LarkHandler:
391
411
  except Exception as e:
392
412
  logger.error(f"启动会话失败: {e}")
393
413
  await card_service.send_text(chat_id, f"错误: 启动失败 - {e}")
414
+ finally:
415
+ self._starting_sessions.discard(session_name)
394
416
 
395
417
  async def _cmd_start_and_new_group(self, user_id: str, chat_id: str,
396
- session_name: str, path: str):
418
+ session_name: str, path: str, cli_type: str = "claude"):
397
419
  """在指定目录启动会话并创建专属群聊"""
398
- sessions = list_active_sessions()
399
- if any(s["name"] == session_name for s in sessions):
400
- # 会话已存在,直接创建群聊
401
- await self._cmd_new_group(user_id, chat_id, session_name)
402
- return
403
-
404
420
  work_path = Path(path).expanduser()
405
421
  if not work_path.is_dir():
406
422
  await card_service.send_text(chat_id, f"错误: 路径无效: {path}")
407
423
  return
408
424
 
425
+ sessions = list_active_sessions()
426
+ active_names = {s["name"] for s in sessions}
427
+ if session_name in active_names or session_name in self._starting_sessions:
428
+ session_name = f"{session_name}_{_datetime.now().strftime('%m%d_%H%M%S')}"
429
+
430
+ self._starting_sessions.add(session_name)
431
+
409
432
  work_dir = str(work_path.absolute())
410
433
  script_dir = Path(__file__).parent.parent.absolute()
411
434
  server_script = script_dir / "server" / "server.py"
412
- cmd = [sys.executable, str(server_script), session_name]
435
+ cmd = ["uv", "run", "--project", str(script_dir), "python3", str(server_script), session_name]
436
+ if cli_type == "codex":
437
+ cmd += ["--cli-type", "codex"]
413
438
  if self._poller.get_bypass_enabled():
414
- cmd += ["--", "--dangerously-skip-permissions", "--permission-mode=dontAsk"]
439
+ if cli_type == "codex":
440
+ cmd += ["--", "--dangerously-bypass-approvals-and-sandbox"]
441
+ else:
442
+ cmd += ["--", "--dangerously-skip-permissions", "--permission-mode=dontAsk"]
415
443
 
416
444
  try:
417
- import os as _os
418
445
  env = _os.environ.copy()
419
446
  env.pop("CLAUDECODE", None)
420
- subprocess.Popen(
421
- cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
422
- start_new_session=True, cwd=work_dir, env=env,
423
- )
447
+
448
+ log_path = USER_DATA_DIR / "startup.log"
449
+ start_time = _datetime.now()
450
+
451
+ with open(log_path, 'a') as stderr_fd:
452
+ proc = subprocess.Popen(
453
+ cmd, stdout=subprocess.DEVNULL, stderr=stderr_fd,
454
+ start_new_session=True, cwd=work_dir, env=env,
455
+ )
424
456
 
425
457
  socket_path = get_socket_path(session_name)
426
- for _ in range(120):
458
+ for i in range(120):
427
459
  await asyncio.sleep(0.1)
428
460
  if socket_path.exists():
429
461
  break
462
+ if (i + 1) % 10 == 0:
463
+ elapsed = (i + 1) // 10
464
+ rc = proc.poll()
465
+ if rc is not None:
466
+ log_content = _read_log_since(start_time, log_path)
467
+ logger.warning(f"启动并创建群聊失败: server 进程已退出 (exitcode={rc}, elapsed={elapsed}s)\n{log_content}")
468
+ await card_service.send_text(chat_id, f"错误: Server 进程意外退出 (code={rc})\n\n{log_content}")
469
+ return
430
470
  else:
431
- await card_service.send_text(chat_id, "错误: 会话启动超时")
471
+ log_content = _read_log_since(start_time, log_path)
472
+ logger.error(f"启动并创建群聊超时 (12s), session={session_name}\n{log_content}")
473
+ await card_service.send_text(chat_id, f"错误: 会话启动超时 (12s)\n\n{log_content}")
432
474
  return
433
475
 
434
476
  await self._cmd_new_group(user_id, chat_id, session_name)
@@ -436,6 +478,8 @@ class LarkHandler:
436
478
  except Exception as e:
437
479
  logger.error(f"启动并创建群聊失败: {e}")
438
480
  await card_service.send_text(chat_id, f"操作失败:{e}")
481
+ finally:
482
+ self._starting_sessions.discard(session_name)
439
483
 
440
484
  async def _cmd_kill(self, user_id: str, chat_id: str, args: str,
441
485
  message_id: Optional[str] = None):
@@ -700,7 +744,8 @@ class LarkHandler:
700
744
  session = next((s for s in sessions if s["name"] == session_name), None)
701
745
  pid = session.get("pid") if session else None
702
746
  cwd = self._get_pid_cwd(pid) if pid else None
703
- dir_label = cwd.rstrip("/").rsplit("/", 1)[-1] if cwd else session_name
747
+ from .card_builder import _get_display_name
748
+ dir_label = _get_display_name(session_name, cwd)
704
749
 
705
750
  from . import config
706
751
  try:
@@ -791,6 +836,32 @@ class LarkHandler:
791
836
  except Exception as e:
792
837
  return False, str(e)
793
838
 
839
+ async def _disband_groups_for_session(self, session_name: str, source: str = ""):
840
+ """解散绑定到指定会话的所有专属群聊"""
841
+ disbanded = []
842
+ for cid in list(self._group_chat_ids):
843
+ if self._chat_bindings.get(cid) == session_name:
844
+ log_prefix = f"[{source}] " if source else ""
845
+ logger.info(f"{log_prefix}自动解散群聊: chat_id={cid[:8]}..., session={session_name}")
846
+ # 先清理本地状态(防止并发协程重入时重复处理)
847
+ self._group_chat_ids.discard(cid)
848
+ self._chat_bindings.pop(cid, None)
849
+ disbanded.append(cid)
850
+ # 停止轮询 + 断开 bridge
851
+ self._poller.stop(cid)
852
+ bridge = self._bridges.pop(cid, None)
853
+ if bridge:
854
+ await bridge.disconnect()
855
+ self._chat_sessions.pop(cid, None)
856
+ self._detached_slices.pop(cid, None)
857
+ # 调用飞书 API 解散
858
+ ok, err = await self._disband_group_via_api(cid)
859
+ if not ok:
860
+ logger.warning(f"{log_prefix}解散群 {cid[:8]}... API 失败: {err}")
861
+ if disbanded:
862
+ self._save_chat_bindings()
863
+ self._save_group_chat_ids()
864
+
794
865
  async def _cmd_disband_group(self, user_id: str, chat_id: str, session_name: str,
795
866
  message_id: Optional[str] = None):
796
867
  """解散与指定会话绑定的专属群聊"""
@@ -842,6 +913,8 @@ class LarkHandler:
842
913
  await card_service.send_text(
843
914
  chat_id, f"会话 '{saved_session}' 已不存在,请重新 /attach"
844
915
  )
916
+ # 会话已不存在,解散绑定到该会话的所有专属群聊
917
+ await self._disband_groups_for_session(saved_session, source="lazy")
845
918
  return
846
919
  bridge = self._bridges.get(chat_id)
847
920
  else:
@@ -861,12 +934,11 @@ class LarkHandler:
861
934
 
862
935
  # ── 选项处理 ─────────────────────────────────────────────────────────────
863
936
 
864
- async def handle_option_select(self, user_id: str, chat_id: str, option_value: str, option_total: int = 0):
865
- """处理用户选择的选项(按钮点击)
937
+ async def handle_option_select(self, user_id: str, chat_id: str, option_value: str, option_total: int = 0, *, needs_input: bool = False):
938
+ """闭环选项选择:箭头键导航 + 共享内存验证
866
939
 
867
- 最后一个选项特殊处理:Claude CLI 的光标选择模式中,最后一个选项
868
- 直接发数字键无效。改为先发倒数第二项的数字跳转,再发 移到最后一项,
869
- 最后发 Enter 确认。
940
+ 发箭头键导航到目标选项,每步从共享内存读取 selected_value 确认是否到位,
941
+ 到位后发 Enter 确认。避免数字键在溢出选项上无效的问题。
870
942
  """
871
943
  logger.info(f"处理选项选择: user={user_id[:8]}..., option={option_value}, total={option_total}")
872
944
  _track_stats('lark', 'option_select',
@@ -878,39 +950,200 @@ class LarkHandler:
878
950
  await card_service.send_text(chat_id, "未连接到任何会话,请先使用 /attach <会话名> 连接")
879
951
  return
880
952
 
881
- key_mapping = {
882
- "yes": "y",
883
- "no": "n",
884
- "allow_once": "y",
885
- "allow_always": "a",
886
- "deny": "n",
953
+ target = option_value # 目标选项 value(如 "2")
954
+ max_steps = max(option_total, 10) if option_total > 0 else 10
955
+
956
+ # 记录初始 option_block 的 block_id,防止跨选项交互误操作
957
+ initial_snapshot = self._poller.read_snapshot(chat_id)
958
+ if not initial_snapshot:
959
+ return
960
+ initial_ob = initial_snapshot.get('option_block')
961
+ if not initial_ob:
962
+ return
963
+ initial_block_id = initial_ob.get('block_id', '')
964
+
965
+ for step in range(max_steps):
966
+ # 1. 读取当前选中项
967
+ snapshot = self._poller.read_snapshot(chat_id)
968
+ if not snapshot:
969
+ break
970
+ ob = snapshot.get('option_block')
971
+ if not ob:
972
+ break # option_block 已消失(CLI 已进入下一状态)
973
+
974
+ # 检查 block_id 一致性
975
+ if initial_block_id and ob.get('block_id', '') != initial_block_id:
976
+ logger.warning(f"option_block 已切换,中止选项选择")
977
+ break
978
+
979
+ current = ob.get('selected_value', '')
980
+
981
+ # 闪烁帧重试:❯ 光标字符会时隐时现,selected_value 为空时短暂重试
982
+ if not current:
983
+ for _retry in range(5): # 最多重试 5 次,共 500ms
984
+ await asyncio.sleep(0.1)
985
+ snap = self._poller.read_snapshot(chat_id)
986
+ if not snap:
987
+ break
988
+ retry_ob = snap.get('option_block')
989
+ if not retry_ob:
990
+ break
991
+ current = retry_ob.get('selected_value', '')
992
+ if current:
993
+ break
994
+
995
+ # 2. 已到位 → 发 Enter(自由输入选项只导航不发 Enter)
996
+ if current == target:
997
+ if needs_input:
998
+ logger.info(f"自由输入选项已到位: target={target},不发送 Enter")
999
+ self._poller.kick(chat_id)
1000
+ return
1001
+ logger.info(f"选项已到位: current={current} == target={target},发送 Enter")
1002
+ success = await bridge.send_raw(b"\r")
1003
+ if success:
1004
+ self._poller.kick(chat_id)
1005
+ else:
1006
+ await card_service.send_text(chat_id, "发送选择失败")
1007
+ return
1008
+
1009
+ # 3. 未到位 → 发箭头键
1010
+ if current and target:
1011
+ try:
1012
+ if int(current) < int(target):
1013
+ logger.info(f"步骤{step}: current={current} < target={target},发送 ↓")
1014
+ await bridge.send_raw(b"\x1b[B") # ↓
1015
+ else:
1016
+ logger.info(f"步骤{step}: current={current} > target={target},发送 ↑")
1017
+ await bridge.send_raw(b"\x1b[A") # ↑
1018
+ except ValueError:
1019
+ logger.warning(f"步骤{step}: 无法比较 current={current!r} 和 target={target!r},发送 ↓")
1020
+ await bridge.send_raw(b"\x1b[B")
1021
+ else:
1022
+ # selected_value 重试后仍为空(真正的初始状态),默认向下
1023
+ logger.info(f"步骤{step}: selected_value 重试后仍为空,发送 ↓")
1024
+ await bridge.send_raw(b"\x1b[B")
1025
+
1026
+ # 4. 等待共享内存更新(轮询直到 selected_value 变为另一个非空值或超时)
1027
+ old_selected = current
1028
+ deadline = time.time() + 2.0 # 单步超时 2s
1029
+ while time.time() < deadline:
1030
+ await asyncio.sleep(0.1) # 100ms 轮询
1031
+ snap = self._poller.read_snapshot(chat_id)
1032
+ if not snap:
1033
+ break
1034
+ new_ob = snap.get('option_block')
1035
+ if not new_ob:
1036
+ break # option_block 消失,退出
1037
+ if initial_block_id and new_ob.get('block_id', '') != initial_block_id:
1038
+ break # block_id 变了,外层会处理
1039
+ new_selected = new_ob.get('selected_value', '')
1040
+ # 忽略闪烁帧:只有变为另一个非空值才视为真正变化
1041
+ if new_selected and new_selected != old_selected:
1042
+ break
1043
+
1044
+ # 超过 max_steps 仍未到位,记录警告
1045
+ logger.warning(f"选项选择超步数: target={target}, steps={max_steps}")
1046
+
1047
+ # ── 快捷键发送 ─────────────────────────────────────────────────────────────
1048
+
1049
+ @staticmethod
1050
+ def _parse_key_combo(combo: str) -> Optional[bytes]:
1051
+ """将用户输入的按键字符串解析为终端转义序列字节,解析失败返回 None"""
1052
+ BASE_KEY_MAP = {
1053
+ "up": b"\x1b[A",
1054
+ "down": b"\x1b[B",
1055
+ "right": b"\x1b[C",
1056
+ "left": b"\x1b[D",
1057
+ "enter": b"\r",
1058
+ "esc": b"\x1b",
1059
+ "tab": b"\t",
1060
+ "backspace": b"\x7f",
1061
+ "delete": b"\x1b[3~",
1062
+ "space": b" ",
1063
+ "home": b"\x1b[H",
1064
+ "end": b"\x1b[F",
1065
+ "pageup": b"\x1b[5~",
1066
+ "pagedown": b"\x1b[6~",
1067
+ "f1": b"\x1bOP", "f2": b"\x1bOQ", "f3": b"\x1bOR", "f4": b"\x1bOS",
1068
+ "f5": b"\x1b[15~", "f6": b"\x1b[17~","f7": b"\x1b[18~","f8": b"\x1b[19~",
1069
+ "f9": b"\x1b[20~", "f10": b"\x1b[21~","f11": b"\x1b[23~","f12": b"\x1b[24~",
887
1070
  }
888
- key_to_send = key_mapping.get(option_value.lower())
889
-
890
- if key_to_send:
891
- # 固定映射的选项(permission 类型)
892
- logger.info(f"发送按键到 Claude: {key_to_send}")
893
- success = await bridge.send_key(key_to_send)
894
- elif option_total > 1 and option_value == str(option_total):
895
- # 最后一个选项:发 (N-1) 次 ↓ → Enter
896
- import asyncio
897
- steps = option_total - 1
898
- logger.info(f"最后一个选项,发送: {steps}次↓ → Enter")
899
- for _ in range(steps):
900
- await bridge.send_raw(b"\x1b[B") # ↓ 箭头
901
- await asyncio.sleep(0.05)
902
- success = await bridge.send_raw(b"\r")
903
- else:
904
- # 普通数字选项
905
- logger.info(f"发送按键到 Claude: {option_value}")
906
- success = await bridge.send_key(option_value)
907
1071
 
1072
+ s = combo.strip().lower()
1073
+ parts = [p.strip() for p in s.split("+")]
1074
+
1075
+ mods = set()
1076
+ keys = []
1077
+ for p in parts:
1078
+ if p in ("ctrl", "alt", "shift"):
1079
+ mods.add(p)
1080
+ else:
1081
+ keys.append(p)
1082
+
1083
+ if len(keys) != 1:
1084
+ return None
1085
+ key = keys[0]
1086
+
1087
+ # ctrl+letter → \x01-\x1a
1088
+ if mods == {"ctrl"}:
1089
+ if len(key) == 1 and 'a' <= key <= 'z':
1090
+ return bytes([ord(key) - ord('a') + 1])
1091
+ # ctrl+[ = ESC, ctrl+\ = FS 等特殊控制字符
1092
+ ctrl_special = {'[': b'\x1b', '\\': b'\x1c', ']': b'\x1d', '^': b'\x1e', '_': b'\x1f'}
1093
+ if key in ctrl_special:
1094
+ return ctrl_special[key]
1095
+ return None
1096
+
1097
+ # alt+key → ESC prefix
1098
+ if mods == {"alt"}:
1099
+ base = BASE_KEY_MAP.get(key)
1100
+ if base:
1101
+ return b"\x1b" + base
1102
+ if len(key) == 1:
1103
+ return b"\x1b" + key.encode()
1104
+ return None
1105
+
1106
+ # shift+tab / shift+enter
1107
+ if mods == {"shift"}:
1108
+ if key == "tab":
1109
+ return b"\x1b[Z"
1110
+ if key == "enter":
1111
+ return b"\x1b[13;2u"
1112
+ return None
1113
+
1114
+ # 无修饰键
1115
+ if not mods:
1116
+ return BASE_KEY_MAP.get(key)
1117
+
1118
+ return None
1119
+
1120
+ async def _cmd_press(self, user_id: str, chat_id: str, args: str):
1121
+ """发送任意按键组合到会话"""
1122
+ combo = args.strip()
1123
+ if not combo:
1124
+ await card_service.send_text(
1125
+ chat_id,
1126
+ "用法:`/press <按键>`\n"
1127
+ "例如:`/press ctrl+c`、`/press esc`、`/press ctrl+f`、`/press alt+x`\n"
1128
+ "支持:ctrl/alt/shift 修饰键,方向键 up/down/left/right,enter/esc/tab/backspace/delete/space/home/end/pageup/pagedown/f1-f12"
1129
+ )
1130
+ return
1131
+
1132
+ raw = LarkHandler._parse_key_combo(combo)
1133
+ if raw is None:
1134
+ await card_service.send_text(chat_id, f"❌ 无法解析按键:`{combo}`\n使用 `/press` 查看支持的按键格式")
1135
+ return
1136
+
1137
+ bridge = self._bridges.get(chat_id)
1138
+ if not bridge or not bridge.running:
1139
+ await card_service.send_text(chat_id, "❌ 当前未连接到会话,请先使用 /attach 连接")
1140
+ return
1141
+
1142
+ success = await bridge.send_raw(raw)
908
1143
  if success:
909
- self._poller.kick(chat_id)
1144
+ logger.info(f"[press] 发送按键 {combo!r} ({raw!r}) 到会话")
910
1145
  else:
911
- await card_service.send_text(chat_id, "发送选择失败")
912
-
913
- # ── 快捷键发送 ─────────────────────────────────────────────────────────────
1146
+ await card_service.send_text(chat_id, f"❌ 发送按键 `{combo}` 失败")
914
1147
 
915
1148
  async def send_raw_key(self, user_id: str, chat_id: str, key_name: str):
916
1149
  """发送原始控制键到 Claude CLI"""
@@ -8,8 +8,10 @@ Remote Claude 飞书客户端
8
8
  import asyncio
9
9
  import json
10
10
  import logging
11
+ import os
11
12
  import signal
12
13
  import sys
14
+ import urllib.request
13
15
  from pathlib import Path
14
16
 
15
17
 
@@ -183,8 +185,9 @@ def handle_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerRespons
183
185
  if action_type == "select_option":
184
186
  option_value = action_value.get("value", "")
185
187
  option_total = int(action_value.get("total", "0"))
186
- print(f"[Lark] 用户选择了选项: {option_value} (total={option_total})")
187
- asyncio.create_task(handler.handle_option_select(user_id, chat_id, option_value, option_total))
188
+ needs_input = action_value.get("needs_input", False)
189
+ print(f"[Lark] 用户选择了选项: {option_value} (total={option_total}, needs_input={needs_input})")
190
+ asyncio.create_task(handler.handle_option_select(user_id, chat_id, option_value, option_total, needs_input=needs_input))
188
191
  return None
189
192
 
190
193
  # 列表卡片:进入会话
@@ -247,16 +250,18 @@ def handle_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerRespons
247
250
  if action_type == "dir_start":
248
251
  path = action_value.get("path", "")
249
252
  session_name = action_value.get("session_name", "")
250
- print(f"[Lark] dir_start: path={path}, session={session_name}")
251
- asyncio.create_task(handler._cmd_start(user_id, chat_id, f"{session_name} {path}"))
253
+ cli_type = action_value.get("cli_type", "claude")
254
+ print(f"[Lark] dir_start: path={path}, session={session_name}, cli_type={cli_type}")
255
+ asyncio.create_task(handler._cmd_start(user_id, chat_id, f"{session_name} {path}", cli_type=cli_type))
252
256
  return None
253
257
 
254
258
  # 目录卡片:在该目录启动会话并创建专属群聊
255
259
  if action_type == "dir_new_group":
256
260
  path = action_value.get("path", "")
257
261
  session_name = action_value.get("session_name", "")
258
- print(f"[Lark] dir_new_group: path={path}, session={session_name}")
259
- asyncio.create_task(handler._cmd_start_and_new_group(user_id, chat_id, session_name, path))
262
+ cli_type = action_value.get("cli_type", "claude")
263
+ print(f"[Lark] dir_new_group: path={path}, session={session_name}, cli_type={cli_type}")
264
+ asyncio.create_task(handler._cmd_start_and_new_group(user_id, chat_id, session_name, path, cli_type=cli_type))
260
265
  return None
261
266
 
262
267
  # /menu 卡片按钮
@@ -375,6 +380,21 @@ class LarkBot:
375
380
  log_level=lark.LogLevel.INFO,
376
381
  )
377
382
 
383
+ # 代理兼容:检测 SOCKS 代理,按配置决定是否绕过
384
+ proxy_info = urllib.request.getproxies()
385
+ socks_proxy = (proxy_info.get('socks') or proxy_info.get('all')
386
+ or proxy_info.get('https') or proxy_info.get('http'))
387
+ if socks_proxy and 'socks' in socks_proxy.lower():
388
+ if config.LARK_NO_PROXY:
389
+ # 用户选择绕过代理 → 清除代理环境变量
390
+ for var in ('ALL_PROXY', 'all_proxy', 'HTTPS_PROXY', 'https_proxy',
391
+ 'HTTP_PROXY', 'http_proxy', 'SOCKS_PROXY', 'socks_proxy'):
392
+ os.environ.pop(var, None)
393
+ print(f"检测到 SOCKS 代理 ({socks_proxy}),已按 LARK_NO_PROXY=1 绕过")
394
+ else:
395
+ print(f"检测到 SOCKS 代理 ({socks_proxy}),将通过代理连接")
396
+ print(" 如连接失败,可在 .env 中设置 LARK_NO_PROXY=1 绕过代理")
397
+
378
398
  self.running = True
379
399
  print("\n机器人已启动,等待消息...")
380
400
  print("在飞书中发送 /help 查看使用说明\n")
@@ -119,22 +119,16 @@ class SessionBridge:
119
119
  if not self.writer or not self.running:
120
120
  return False
121
121
  try:
122
+ # 分两步发送:先发文本,等 Ink 框架处理完毕,再发 Enter
123
+ # 不能合并为 text+\r 一次写入(Ink 同一 tick 处理时 \r 会被提前消费,导致需要两次 Enter)
124
+ # 不能在文本前/后加 ESC(ESC+Enter 被终端解释为 Alt+Enter,导致换行而非提交)
122
125
  msg = InputMessage(text.encode('utf-8'), self.client_id)
123
126
  self.writer.write(encode_message(msg))
124
127
  await self.writer.drain()
125
-
126
- # 发送 Escape 退出多行模式
127
- await asyncio.sleep(0.1)
128
- msg = InputMessage(b"\x1b", self.client_id)
129
- self.writer.write(encode_message(msg))
130
- await self.writer.drain()
131
-
132
- # 发送 Enter 提交
133
- await asyncio.sleep(0.1)
128
+ await asyncio.sleep(0.05)
134
129
  msg = InputMessage(b"\r", self.client_id)
135
130
  self.writer.write(encode_message(msg))
136
131
  await self.writer.drain()
137
-
138
132
  return True
139
133
  except Exception as e:
140
134
  logger.error(f"发送失败: {e}")