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.
- package/.env.example +8 -0
- package/README.md +0 -2
- package/bin/cdx +9 -1
- package/bin/cl +9 -1
- package/bin/cla +9 -1
- package/bin/cx +9 -1
- package/bin/remote-claude +9 -18
- package/client/client.py +1 -1
- package/init.sh +30 -97
- package/lark_client/card_builder.py +60 -33
- package/lark_client/config.py +4 -0
- package/lark_client/lark_handler.py +316 -83
- package/lark_client/main.py +26 -6
- package/lark_client/session_bridge.py +4 -10
- package/lark_client/shared_memory_poller.py +193 -107
- package/package.json +1 -1
- package/pyproject.toml +1 -0
- package/remote_claude.py +226 -0
- package/server/parsers/claude_parser.py +43 -0
- package/server/parsers/codex_parser.py +8 -0
- package/server/server.py +31 -19
- package/utils/components.py +1 -0
- package/utils/session.py +81 -30
|
@@ -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 = [
|
|
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
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
-
|
|
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"""
|
package/lark_client/main.py
CHANGED
|
@@ -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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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}")
|