remote-claude 1.0.7 → 1.0.9
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/lark_client/lark_handler.py +166 -29
- package/lark_client/main.py +14 -0
- package/lark_client/session_bridge.py +3 -1
- package/lark_client/shared_memory_poller.py +4 -2
- package/package.json +1 -1
- package/remote_claude.py +43 -5
- package/utils/session.py +41 -0
|
@@ -87,42 +87,63 @@ class LarkHandler:
|
|
|
87
87
|
self._detached_slices: Dict[str, CardSlice] = {}
|
|
88
88
|
# 正在启动中的会话名集合(防止并发点击触发竞态)
|
|
89
89
|
self._starting_sessions: set = set()
|
|
90
|
+
# Session 健康检查后台任务
|
|
91
|
+
self._health_check_task: Optional[asyncio.Task] = None
|
|
92
|
+
logger.info(
|
|
93
|
+
f"LarkHandler 初始化: bindings={len(self._chat_bindings)}, "
|
|
94
|
+
f"groups={len(self._group_chat_ids)}, "
|
|
95
|
+
f"binding_details={{{', '.join(f'{k[:8]}...→{v}' for k, v in self._chat_bindings.items())}}}"
|
|
96
|
+
)
|
|
90
97
|
|
|
91
98
|
# ── 持久化绑定 ──────────────────────────────────────────────────────────
|
|
92
99
|
|
|
93
|
-
def
|
|
100
|
+
def _load_json_file(self, path: Path):
|
|
101
|
+
"""原子读取 JSON 文件,损坏时尝试从 .tmp 备份恢复"""
|
|
94
102
|
try:
|
|
95
|
-
if
|
|
96
|
-
|
|
103
|
+
if path.exists():
|
|
104
|
+
content = path.read_text()
|
|
105
|
+
if content.strip():
|
|
106
|
+
return json.loads(content)
|
|
107
|
+
except json.JSONDecodeError as e:
|
|
108
|
+
logger.warning(f"文件 {path.name} JSON 损坏: {e},尝试恢复备份")
|
|
109
|
+
tmp_path = path.with_suffix('.json.tmp')
|
|
110
|
+
if tmp_path.exists():
|
|
111
|
+
try:
|
|
112
|
+
content = tmp_path.read_text()
|
|
113
|
+
if content.strip():
|
|
114
|
+
result = json.loads(content)
|
|
115
|
+
logger.warning(f"从 {tmp_path.name} 恢复成功")
|
|
116
|
+
return result
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
97
119
|
except Exception:
|
|
98
120
|
pass
|
|
99
|
-
return
|
|
121
|
+
return None
|
|
100
122
|
|
|
101
|
-
def
|
|
123
|
+
def _atomic_write_json(self, path: Path, data):
|
|
124
|
+
"""原子写入 JSON 文件:先写 .tmp 再 rename"""
|
|
102
125
|
try:
|
|
103
126
|
ensure_user_data_dir()
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
)
|
|
127
|
+
text = json.dumps(data, ensure_ascii=False)
|
|
128
|
+
tmp_path = path.with_suffix('.json.tmp')
|
|
129
|
+
tmp_path.write_text(text)
|
|
130
|
+
tmp_path.rename(path)
|
|
107
131
|
except Exception as e:
|
|
108
|
-
logger.warning(f"
|
|
132
|
+
logger.warning(f"保存 {path.name} 失败: {e}")
|
|
133
|
+
|
|
134
|
+
def _load_chat_bindings(self) -> Dict[str, str]:
|
|
135
|
+
result = self._load_json_file(self._CHAT_BINDINGS_FILE)
|
|
136
|
+
return result if result is not None else {}
|
|
137
|
+
|
|
138
|
+
def _save_chat_bindings(self):
|
|
139
|
+
self._atomic_write_json(self._CHAT_BINDINGS_FILE, self._chat_bindings)
|
|
109
140
|
|
|
110
141
|
def _load_group_chat_ids(self) -> set:
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
return set(json.loads(self._LARK_GROUP_IDS_FILE.read_text()))
|
|
114
|
-
except Exception:
|
|
115
|
-
pass
|
|
116
|
-
return set()
|
|
142
|
+
result = self._load_json_file(self._LARK_GROUP_IDS_FILE)
|
|
143
|
+
return set(result) if result is not None else set()
|
|
117
144
|
|
|
118
145
|
def _save_group_chat_ids(self):
|
|
119
|
-
|
|
120
|
-
ensure_user_data_dir()
|
|
121
|
-
self._LARK_GROUP_IDS_FILE.write_text(
|
|
122
|
-
json.dumps(list(self._group_chat_ids), ensure_ascii=False)
|
|
123
|
-
)
|
|
124
|
-
except Exception as e:
|
|
125
|
-
logger.warning(f"保存群聊 ID 失败: {e}")
|
|
146
|
+
self._atomic_write_json(self._LARK_GROUP_IDS_FILE, list(self._group_chat_ids))
|
|
126
147
|
|
|
127
148
|
def _remove_binding_by_chat(self, chat_id: str, force: bool = False):
|
|
128
149
|
"""移除 chat_id 的绑定。
|
|
@@ -131,6 +152,9 @@ class LarkHandler:
|
|
|
131
152
|
"""
|
|
132
153
|
if not force and chat_id in self._group_chat_ids:
|
|
133
154
|
return
|
|
155
|
+
session = self._chat_bindings.get(chat_id)
|
|
156
|
+
if session is not None:
|
|
157
|
+
logger.info(f"移除绑定: chat_id={chat_id[:8]}..., session={session}, force={force}")
|
|
134
158
|
self._chat_bindings.pop(chat_id, None)
|
|
135
159
|
self._save_chat_bindings()
|
|
136
160
|
|
|
@@ -139,6 +163,12 @@ class LarkHandler:
|
|
|
139
163
|
async def _attach(self, chat_id: str, session_name: str,
|
|
140
164
|
user_id: Optional[str] = None) -> bool:
|
|
141
165
|
"""统一 attach 逻辑(私聊/群聊共用)"""
|
|
166
|
+
self._ensure_health_check_started()
|
|
167
|
+
logger.info(
|
|
168
|
+
f"[ATTACH] 开始: chat_id={chat_id[:8]}..., session={session_name}, "
|
|
169
|
+
f"old_session={self._chat_sessions.get(chat_id)}, "
|
|
170
|
+
f"old_bridge={'有' if chat_id in self._bridges else '无'}"
|
|
171
|
+
)
|
|
142
172
|
# 在断开旧连接之前,先更新旧流式卡片为已断开状态
|
|
143
173
|
old_session = self._chat_sessions.get(chat_id)
|
|
144
174
|
old_slice = self._poller.stop_and_get_active_slice(chat_id)
|
|
@@ -165,11 +195,15 @@ class LarkHandler:
|
|
|
165
195
|
notify_user_id=user_id)
|
|
166
196
|
_track_stats('lark', 'attach', session_name=session_name,
|
|
167
197
|
chat_id=chat_id)
|
|
198
|
+
logger.info(f"[ATTACH] 成功: chat_id={chat_id[:8]}..., session={session_name}")
|
|
168
199
|
return True
|
|
200
|
+
logger.warning(f"[ATTACH] 失败: chat_id={chat_id[:8]}..., session={session_name}")
|
|
169
201
|
return False
|
|
170
202
|
|
|
171
203
|
async def _detach(self, chat_id: str):
|
|
172
204
|
"""统一 detach 逻辑(私聊/群聊共用)"""
|
|
205
|
+
session_name = self._chat_sessions.get(chat_id, "未知")
|
|
206
|
+
logger.info(f"[DETACH] chat_id={chat_id[:8]}..., session={session_name}")
|
|
173
207
|
bridge = self._bridges.pop(chat_id, None)
|
|
174
208
|
if bridge:
|
|
175
209
|
await bridge.disconnect()
|
|
@@ -185,7 +219,12 @@ class LarkHandler:
|
|
|
185
219
|
logger.info(f"会话 '{session_name}' 旧连接断线(已被替换),跳过清理")
|
|
186
220
|
return
|
|
187
221
|
|
|
188
|
-
|
|
222
|
+
bridge_running = disconnected_bridge.running if disconnected_bridge else "N/A"
|
|
223
|
+
logger.info(f"会话 '{session_name}' 断线, chat_id={chat_id[:8]}..., bridge_running={bridge_running}")
|
|
224
|
+
logger.info(
|
|
225
|
+
f"[DISCONNECT] 状态快照: bridges={[k[:8] for k in self._bridges]}, "
|
|
226
|
+
f"sessions={{{', '.join(f'{k[:8]}→{v}' for k, v in self._chat_sessions.items())}}}"
|
|
227
|
+
)
|
|
189
228
|
_track_stats('lark', 'disconnect', session_name=session_name,
|
|
190
229
|
chat_id=chat_id)
|
|
191
230
|
active_slice = self._poller.stop_and_get_active_slice(chat_id)
|
|
@@ -196,13 +235,19 @@ class LarkHandler:
|
|
|
196
235
|
# 只有用户主动 /detach 或 /kill 时才清除绑定
|
|
197
236
|
|
|
198
237
|
if active_slice:
|
|
199
|
-
await self._update_card_disconnected(chat_id, session_name, active_slice)
|
|
238
|
+
updated = await self._update_card_disconnected(chat_id, session_name, active_slice)
|
|
239
|
+
if not updated:
|
|
240
|
+
# 就地更新失败 → 降级发新卡片,确保用户看到断开状态
|
|
241
|
+
logger.warning(f"断线卡片就地更新失败,降级发送新卡: {chat_id[:8]}...")
|
|
242
|
+
card = build_stream_card([], disconnected=True, session_name=session_name)
|
|
243
|
+
await card_service.create_and_send_card(chat_id, card)
|
|
200
244
|
|
|
201
245
|
# ── 消息入口 ────────────────────────────────────────────────────────────
|
|
202
246
|
|
|
203
247
|
async def handle_message(self, user_id: str, chat_id: str, text: str,
|
|
204
248
|
chat_type: str = "p2p"):
|
|
205
249
|
"""处理用户消息(群聊/私聊统一路由)"""
|
|
250
|
+
self._ensure_health_check_started()
|
|
206
251
|
logger.info(f"收到消息: user={user_id[:8]}..., chat={chat_id[:8]}..., type={chat_type}, text={text[:50]}")
|
|
207
252
|
text = text.strip()
|
|
208
253
|
|
|
@@ -280,6 +325,7 @@ class LarkHandler:
|
|
|
280
325
|
|
|
281
326
|
ok = await self._attach(chat_id, session_name, user_id=user_id)
|
|
282
327
|
if ok:
|
|
328
|
+
logger.info(f"[BINDING] 写入: chat_id={chat_id[:8]}..., session={session_name}, source=cmd_attach")
|
|
283
329
|
self._chat_bindings[chat_id] = session_name
|
|
284
330
|
self._save_chat_bindings()
|
|
285
331
|
if message_id:
|
|
@@ -406,6 +452,7 @@ class LarkHandler:
|
|
|
406
452
|
|
|
407
453
|
ok = await self._attach(chat_id, session_name, user_id=user_id)
|
|
408
454
|
if ok:
|
|
455
|
+
logger.info(f"[BINDING] 写入: chat_id={chat_id[:8]}..., session={session_name}, source=cmd_start")
|
|
409
456
|
self._chat_bindings[chat_id] = session_name
|
|
410
457
|
self._save_chat_bindings()
|
|
411
458
|
else:
|
|
@@ -520,6 +567,7 @@ class LarkHandler:
|
|
|
520
567
|
|
|
521
568
|
# 清理所有残留绑定(包括已断开的群聊,其绑定在断开时被保留)
|
|
522
569
|
for cid in [c for c, s in list(self._chat_bindings.items()) if s == session_name]:
|
|
570
|
+
logger.info(f"[KILL] 清理残留绑定: chat_id={cid[:8]}..., session={session_name}, is_group={'是' if cid in self._group_chat_ids else '否'}")
|
|
523
571
|
self._group_chat_ids.discard(cid)
|
|
524
572
|
self._chat_bindings.pop(cid, None)
|
|
525
573
|
self._save_chat_bindings()
|
|
@@ -547,7 +595,7 @@ class LarkHandler:
|
|
|
547
595
|
|
|
548
596
|
async def _update_card_disconnected(self, chat_id: str, session_name: str,
|
|
549
597
|
active_slice: 'CardSlice') -> bool:
|
|
550
|
-
"""读取最新 blocks 并就地更新卡片为断开状态(disconnected=True
|
|
598
|
+
"""读取最新 blocks 并就地更新卡片为断开状态(disconnected=True)。返回是否成功。"""
|
|
551
599
|
blocks = []
|
|
552
600
|
try:
|
|
553
601
|
import sys as _sys
|
|
@@ -795,6 +843,7 @@ class LarkHandler:
|
|
|
795
843
|
self._save_chat_bindings()
|
|
796
844
|
self._group_chat_ids.add(group_chat_id)
|
|
797
845
|
self._save_group_chat_ids()
|
|
846
|
+
logger.info(f"创建群聊成功: group_chat_id={group_chat_id[:8]}..., session={session_name}")
|
|
798
847
|
# 立即 attach,让新群即刻开始接收 Claude 输出
|
|
799
848
|
await self._attach(group_chat_id, session_name, user_id=user_id)
|
|
800
849
|
|
|
@@ -868,6 +917,68 @@ class LarkHandler:
|
|
|
868
917
|
self._save_chat_bindings()
|
|
869
918
|
self._save_group_chat_ids()
|
|
870
919
|
|
|
920
|
+
# ── Session 健康检查 ─────────────────────────────────────────────────────
|
|
921
|
+
|
|
922
|
+
HEALTH_CHECK_INTERVAL = 30 # 秒
|
|
923
|
+
|
|
924
|
+
def _ensure_health_check_started(self):
|
|
925
|
+
"""懒启动健康检查后台任务(首次收到消息时调用)"""
|
|
926
|
+
if self._health_check_task is None or self._health_check_task.done():
|
|
927
|
+
self._health_check_task = asyncio.create_task(self._health_check_loop())
|
|
928
|
+
logger.info("Session 健康检查后台任务已启动")
|
|
929
|
+
|
|
930
|
+
async def _health_check_loop(self):
|
|
931
|
+
"""定期检查绑定的 session 是否仍然存活"""
|
|
932
|
+
while True:
|
|
933
|
+
await asyncio.sleep(self.HEALTH_CHECK_INTERVAL)
|
|
934
|
+
try:
|
|
935
|
+
await self._session_health_check()
|
|
936
|
+
except Exception as e:
|
|
937
|
+
logger.error(f"健康检查异常: {e}", exc_info=True)
|
|
938
|
+
|
|
939
|
+
async def _session_health_check(self):
|
|
940
|
+
"""扫描所有绑定关系,对已死亡 session 自动解散群聊、清理私聊绑定"""
|
|
941
|
+
bound_sessions = set(self._chat_bindings.values())
|
|
942
|
+
if not bound_sessions:
|
|
943
|
+
return
|
|
944
|
+
|
|
945
|
+
active = {s["name"] for s in list_active_sessions()}
|
|
946
|
+
dead_sessions = bound_sessions - active
|
|
947
|
+
if not dead_sessions:
|
|
948
|
+
return
|
|
949
|
+
|
|
950
|
+
for session_name in dead_sessions:
|
|
951
|
+
logger.info(f"[health-check] 检测到 session '{session_name}' 已关闭")
|
|
952
|
+
|
|
953
|
+
# 记录受影响的 chat_id(在 disband 修改 dict 之前)
|
|
954
|
+
affected_chats = [
|
|
955
|
+
cid for cid, sname in list(self._chat_bindings.items())
|
|
956
|
+
if sname == session_name
|
|
957
|
+
]
|
|
958
|
+
|
|
959
|
+
# 解散群聊(复用已有逻辑,内部会清理群聊的绑定/轮询/bridge)
|
|
960
|
+
await self._disband_groups_for_session(session_name, source="health-check")
|
|
961
|
+
|
|
962
|
+
# 处理私聊绑定(非群聊的 chat_id)
|
|
963
|
+
cleaned_private = False
|
|
964
|
+
for cid in affected_chats:
|
|
965
|
+
if cid in self._group_chat_ids:
|
|
966
|
+
continue # 群聊已由 _disband_groups_for_session 处理
|
|
967
|
+
if self._chat_bindings.get(cid) != session_name:
|
|
968
|
+
continue # 已被其他逻辑清理
|
|
969
|
+
logger.info(f"[health-check] 清理私聊绑定: chat_id={cid[:8]}..., session={session_name}")
|
|
970
|
+
self._poller.stop(cid)
|
|
971
|
+
bridge = self._bridges.pop(cid, None)
|
|
972
|
+
if bridge:
|
|
973
|
+
await bridge.disconnect()
|
|
974
|
+
self._chat_sessions.pop(cid, None)
|
|
975
|
+
self._detached_slices.pop(cid, None)
|
|
976
|
+
self._remove_binding_by_chat(cid, force=True)
|
|
977
|
+
cleaned_private = True
|
|
978
|
+
|
|
979
|
+
if cleaned_private:
|
|
980
|
+
self._save_chat_bindings()
|
|
981
|
+
|
|
871
982
|
async def _cmd_disband_group(self, user_id: str, chat_id: str, session_name: str,
|
|
872
983
|
message_id: Optional[str] = None):
|
|
873
984
|
"""解散与指定会话绑定的专属群聊"""
|
|
@@ -885,6 +996,7 @@ class LarkHandler:
|
|
|
885
996
|
logger.error(f"解散群 API 失败: {feishu_msg}")
|
|
886
997
|
|
|
887
998
|
# 无论 Feishu delete 是否成功,都清理本地绑定
|
|
999
|
+
logger.info(f"[DISBAND] 单群解散: group_chat_id={group_chat_id[:8]}..., session={session_name}")
|
|
888
1000
|
self._group_chat_ids.discard(group_chat_id)
|
|
889
1001
|
self._save_group_chat_ids()
|
|
890
1002
|
self._remove_binding_by_chat(group_chat_id, force=True)
|
|
@@ -907,6 +1019,8 @@ class LarkHandler:
|
|
|
907
1019
|
bridge = self._bridges.get(chat_id)
|
|
908
1020
|
if bridge and bridge.running:
|
|
909
1021
|
return bridge
|
|
1022
|
+
if bridge and not bridge.running:
|
|
1023
|
+
logger.info(f"bridge 存在但已停止: chat_id={chat_id[:8]}..., session={self._chat_sessions.get(chat_id)}")
|
|
910
1024
|
# 尝试从持久化绑定恢复
|
|
911
1025
|
saved_session = self._chat_bindings.get(chat_id)
|
|
912
1026
|
if saved_session:
|
|
@@ -914,11 +1028,28 @@ class LarkHandler:
|
|
|
914
1028
|
ok = await self._attach(chat_id, saved_session, user_id=user_id)
|
|
915
1029
|
if ok:
|
|
916
1030
|
return self._bridges.get(chat_id)
|
|
917
|
-
#
|
|
1031
|
+
# 区分临时失败和永久失败
|
|
1032
|
+
sessions = list_active_sessions()
|
|
1033
|
+
if any(s["name"] == saved_session for s in sessions):
|
|
1034
|
+
# 会话存在但连接失败 → 临时性故障,保留绑定供重试
|
|
1035
|
+
logger.warning(f"自动恢复失败(临时性): session={saved_session}, 保留绑定")
|
|
1036
|
+
return None
|
|
1037
|
+
# 会话已不存在 → 永久失败,级联清除
|
|
1038
|
+
logger.warning(f"会话 '{saved_session}' 已不存在,清除绑定: chat_id={chat_id[:8]}...")
|
|
1039
|
+
# 1. 先停止当前 chat_id 的轮询器和 bridge
|
|
1040
|
+
self._poller.stop(chat_id)
|
|
1041
|
+
stale_bridge = self._bridges.pop(chat_id, None)
|
|
1042
|
+
if stale_bridge:
|
|
1043
|
+
await stale_bridge.disconnect()
|
|
1044
|
+
self._chat_sessions.pop(chat_id, None)
|
|
1045
|
+
self._detached_slices.pop(chat_id, None)
|
|
1046
|
+
# 2. 清除绑定
|
|
918
1047
|
self._group_chat_ids.discard(chat_id)
|
|
919
|
-
self._save_group_chat_ids()
|
|
920
1048
|
self._remove_binding_by_chat(chat_id, force=True)
|
|
1049
|
+
# 3. 解散同会话的其他群聊(内部会清理各自的轮询器/bridge/绑定)
|
|
921
1050
|
await self._disband_groups_for_session(saved_session, source="lazy")
|
|
1051
|
+
# 4. 确保持久化保存(disband 按绑定匹配遍历,已在上面移除绑定,确保保存)
|
|
1052
|
+
self._save_group_chat_ids()
|
|
922
1053
|
return None
|
|
923
1054
|
|
|
924
1055
|
async def _forward_to_claude(self, user_id: str, chat_id: str, text: str):
|
|
@@ -945,6 +1076,7 @@ class LarkHandler:
|
|
|
945
1076
|
发箭头键导航到目标选项,每步从共享内存读取 selected_value 确认是否到位,
|
|
946
1077
|
到位后发 Enter 确认。避免数字键在溢出选项上无效的问题。
|
|
947
1078
|
"""
|
|
1079
|
+
self._ensure_health_check_started()
|
|
948
1080
|
logger.info(f"处理选项选择: user={user_id[:8]}..., option={option_value}, total={option_total}")
|
|
949
1081
|
_track_stats('lark', 'option_select',
|
|
950
1082
|
session_name=self._chat_sessions.get(chat_id, ''),
|
|
@@ -1139,14 +1271,15 @@ class LarkHandler:
|
|
|
1139
1271
|
await card_service.send_text(chat_id, f"❌ 无法解析按键:`{combo}`\n使用 `/press` 查看支持的按键格式")
|
|
1140
1272
|
return
|
|
1141
1273
|
|
|
1142
|
-
bridge = self.
|
|
1143
|
-
if not bridge
|
|
1274
|
+
bridge = await self._ensure_bridge(chat_id, user_id=user_id)
|
|
1275
|
+
if not bridge:
|
|
1144
1276
|
await card_service.send_text(chat_id, "❌ 当前未连接到会话,请先使用 /attach 连接")
|
|
1145
1277
|
return
|
|
1146
1278
|
|
|
1147
1279
|
success = await bridge.send_raw(raw)
|
|
1148
1280
|
if success:
|
|
1149
1281
|
logger.info(f"[press] 发送按键 {combo!r} ({raw!r}) 到会话")
|
|
1282
|
+
self._poller.kick(chat_id)
|
|
1150
1283
|
else:
|
|
1151
1284
|
await card_service.send_text(chat_id, f"❌ 发送按键 `{combo}` 失败")
|
|
1152
1285
|
|
|
@@ -1241,6 +1374,10 @@ class LarkHandler:
|
|
|
1241
1374
|
|
|
1242
1375
|
async def disconnect_all_for_shutdown(self) -> None:
|
|
1243
1376
|
"""lark stop 时清理所有活跃流式卡片(更新为已断开状态)"""
|
|
1377
|
+
# 停止健康检查
|
|
1378
|
+
if self._health_check_task and not self._health_check_task.done():
|
|
1379
|
+
self._health_check_task.cancel()
|
|
1380
|
+
logger.info(f"[SHUTDOWN] 开始清理: bridges={len(self._bridges)}, sessions={len(self._chat_sessions)}")
|
|
1244
1381
|
chat_ids = list(self._bridges.keys())
|
|
1245
1382
|
for chat_id in chat_ids:
|
|
1246
1383
|
session_name = self._chat_sessions.get(chat_id, "")
|
package/lark_client/main.py
CHANGED
|
@@ -11,6 +11,8 @@ import logging
|
|
|
11
11
|
import os
|
|
12
12
|
import signal
|
|
13
13
|
import sys
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
14
16
|
import urllib.request
|
|
15
17
|
from pathlib import Path
|
|
16
18
|
|
|
@@ -155,6 +157,7 @@ def handle_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerRespons
|
|
|
155
157
|
action_value = action.value or {}
|
|
156
158
|
|
|
157
159
|
print(f"[Lark] 收到卡片动作: user={user_id[:8]}..., action={action_value}")
|
|
160
|
+
handler._ensure_health_check_started()
|
|
158
161
|
|
|
159
162
|
# 检查用户白名单
|
|
160
163
|
if not check_user_allowed(user_id):
|
|
@@ -399,6 +402,17 @@ class LarkBot:
|
|
|
399
402
|
print("\n机器人已启动,等待消息...")
|
|
400
403
|
print("在飞书中发送 /help 查看使用说明\n")
|
|
401
404
|
|
|
405
|
+
# 在 ws_client.start() 阻塞前,保存事件循环引用,通过守护线程延迟启动健康检查
|
|
406
|
+
# (无需等待消息,15 秒后 WS 已连接就自动启动)
|
|
407
|
+
_loop = asyncio.get_event_loop()
|
|
408
|
+
def _deferred_health_check_start():
|
|
409
|
+
time.sleep(15)
|
|
410
|
+
try:
|
|
411
|
+
_loop.call_soon_threadsafe(handler._ensure_health_check_started)
|
|
412
|
+
except Exception as e:
|
|
413
|
+
print(f"[Lark] 健康检查启动失败: {e}")
|
|
414
|
+
threading.Thread(target=_deferred_health_check_start, daemon=True).start()
|
|
415
|
+
|
|
402
416
|
# 启动 WebSocket(阻塞)
|
|
403
417
|
self.ws_client.start()
|
|
404
418
|
|
|
@@ -99,6 +99,7 @@ class SessionBridge:
|
|
|
99
99
|
|
|
100
100
|
async def disconnect(self):
|
|
101
101
|
"""断开连接"""
|
|
102
|
+
logger.info(f"主动断开: session={self.session_name}, running={self.running}")
|
|
102
103
|
self._manually_disconnected = True
|
|
103
104
|
self.running = False
|
|
104
105
|
if self._read_task:
|
|
@@ -173,6 +174,7 @@ class SessionBridge:
|
|
|
173
174
|
try:
|
|
174
175
|
msg = await asyncio.wait_for(self._read_message(), timeout=1.0)
|
|
175
176
|
if msg is None:
|
|
177
|
+
logger.info(f"read_loop 收到 EOF: session={self.session_name}")
|
|
176
178
|
self.running = False
|
|
177
179
|
break
|
|
178
180
|
if msg.type == MessageType.INPUT and self.on_input:
|
|
@@ -183,7 +185,7 @@ class SessionBridge:
|
|
|
183
185
|
except asyncio.CancelledError:
|
|
184
186
|
break
|
|
185
187
|
except Exception as e:
|
|
186
|
-
logger.warning(f"读取错误: {e}")
|
|
188
|
+
logger.warning(f"读取错误: session={self.session_name}, error={e}")
|
|
187
189
|
break
|
|
188
190
|
|
|
189
191
|
if self.on_disconnect and not self._manually_disconnected:
|
|
@@ -113,12 +113,13 @@ class SharedMemoryPoller:
|
|
|
113
113
|
self._rapid_until.pop(chat_id, None)
|
|
114
114
|
|
|
115
115
|
tracker = self._trackers.pop(chat_id, None)
|
|
116
|
+
session_name = tracker.session_name if tracker else "N/A"
|
|
116
117
|
if tracker and tracker.reader:
|
|
117
118
|
try:
|
|
118
119
|
tracker.reader.close()
|
|
119
120
|
except Exception:
|
|
120
121
|
pass
|
|
121
|
-
logger.info(f"轮询器停止: chat_id={chat_id[:8]}
|
|
122
|
+
logger.info(f"轮询器停止: chat_id={chat_id[:8]}..., session={session_name}")
|
|
122
123
|
|
|
123
124
|
def stop_and_get_active_slice(self, chat_id: str) -> Optional['CardSlice']:
|
|
124
125
|
"""停止轮询并返回活跃(未冻结)CardSlice,原子操作。供 detach/disconnect 就地更新卡片使用。"""
|
|
@@ -143,7 +144,8 @@ class SharedMemoryPoller:
|
|
|
143
144
|
except Exception:
|
|
144
145
|
pass
|
|
145
146
|
|
|
146
|
-
|
|
147
|
+
session_name = tracker.session_name if tracker else "N/A"
|
|
148
|
+
logger.info(f"轮询器停止(含活跃切片): chat_id={chat_id[:8]}..., session={session_name}, active={'有' if active else '无'}")
|
|
147
149
|
return active
|
|
148
150
|
|
|
149
151
|
def _on_task_done(self, task: asyncio.Task, chat_id: str) -> None:
|
package/package.json
CHANGED
package/remote_claude.py
CHANGED
|
@@ -34,7 +34,7 @@ from utils.session import (
|
|
|
34
34
|
tmux_kill_session,
|
|
35
35
|
list_active_sessions, is_session_active, cleanup_session,
|
|
36
36
|
is_lark_running, get_lark_pid, get_lark_status, get_lark_pid_file,
|
|
37
|
-
save_lark_status, cleanup_lark,
|
|
37
|
+
save_lark_status, cleanup_lark, kill_all_lark_processes,
|
|
38
38
|
USER_DATA_DIR, ensure_user_data_dir, get_lark_log_file,
|
|
39
39
|
get_env_snapshot_path,
|
|
40
40
|
)
|
|
@@ -214,10 +214,28 @@ def cmd_list(args):
|
|
|
214
214
|
return 0
|
|
215
215
|
|
|
216
216
|
|
|
217
|
+
def _find_session_by_pid(pid: int):
|
|
218
|
+
"""通过 PID 查找对应会话名"""
|
|
219
|
+
for session in list_active_sessions():
|
|
220
|
+
if session.get("pid") == pid:
|
|
221
|
+
return session["name"]
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
|
|
217
225
|
def cmd_kill(args):
|
|
218
226
|
"""终止会话"""
|
|
219
227
|
session_name = args.name
|
|
220
228
|
|
|
229
|
+
# 若参数是纯数字,按 PID 反查会话名
|
|
230
|
+
if session_name.isdigit():
|
|
231
|
+
pid = int(session_name)
|
|
232
|
+
matched = _find_session_by_pid(pid)
|
|
233
|
+
if not matched:
|
|
234
|
+
print(f"错误: 未找到 PID={pid} 对应的会话")
|
|
235
|
+
return 1
|
|
236
|
+
session_name = matched
|
|
237
|
+
print(f"PID {pid} 对应会话: {session_name}")
|
|
238
|
+
|
|
221
239
|
# 检查会话是否存在
|
|
222
240
|
if not is_session_active(session_name) and not tmux_session_exists(session_name):
|
|
223
241
|
print(f"错误: 会话 '{session_name}' 不存在")
|
|
@@ -307,6 +325,11 @@ def cmd_lark_start(args):
|
|
|
307
325
|
ensure_socket_dir()
|
|
308
326
|
ensure_user_data_dir()
|
|
309
327
|
|
|
328
|
+
# 启动前清理残留进程
|
|
329
|
+
stale = kill_all_lark_processes()
|
|
330
|
+
if stale:
|
|
331
|
+
print(f" 清理了 {len(stale)} 个残留进程: {stale}")
|
|
332
|
+
|
|
310
333
|
# 启动守护进程(使用 -m 模块方式运行,确保相对导入正常工作)
|
|
311
334
|
log_file = get_lark_log_file()
|
|
312
335
|
|
|
@@ -364,8 +387,11 @@ def cmd_lark_stop(args):
|
|
|
364
387
|
print(f"正在停止飞书客户端 (PID: {pid})...")
|
|
365
388
|
|
|
366
389
|
try:
|
|
367
|
-
#
|
|
368
|
-
|
|
390
|
+
# 杀整个进程组(uv 父进程 + Python 子进程一起杀)
|
|
391
|
+
try:
|
|
392
|
+
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
|
393
|
+
except (ProcessLookupError, PermissionError):
|
|
394
|
+
os.kill(pid, signal.SIGTERM) # 降级
|
|
369
395
|
|
|
370
396
|
# 等待进程退出
|
|
371
397
|
for i in range(50): # 最多等待 5 秒
|
|
@@ -375,9 +401,17 @@ def cmd_lark_stop(args):
|
|
|
375
401
|
else:
|
|
376
402
|
# 如果还没退出,强制终止
|
|
377
403
|
print("进程未响应,强制终止...")
|
|
378
|
-
|
|
404
|
+
try:
|
|
405
|
+
os.killpg(os.getpgid(pid), signal.SIGKILL)
|
|
406
|
+
except (ProcessLookupError, PermissionError):
|
|
407
|
+
os.kill(pid, signal.SIGKILL)
|
|
379
408
|
time.sleep(0.5)
|
|
380
409
|
|
|
410
|
+
# 清理所有残留 lark_client 进程
|
|
411
|
+
stale = kill_all_lark_processes()
|
|
412
|
+
if stale:
|
|
413
|
+
print(f" 清理了 {len(stale)} 个残留进程: {stale}")
|
|
414
|
+
|
|
381
415
|
if not is_lark_running():
|
|
382
416
|
print("✓ 飞书客户端已停止")
|
|
383
417
|
cleanup_lark()
|
|
@@ -389,6 +423,10 @@ def cmd_lark_stop(args):
|
|
|
389
423
|
return 1
|
|
390
424
|
|
|
391
425
|
except ProcessLookupError:
|
|
426
|
+
# 清理所有残留 lark_client 进程
|
|
427
|
+
stale = kill_all_lark_processes()
|
|
428
|
+
if stale:
|
|
429
|
+
print(f" 清理了 {len(stale)} 个残留进程: {stale}")
|
|
392
430
|
print("进程已不存在,清理残留文件")
|
|
393
431
|
cleanup_lark()
|
|
394
432
|
_stop_watchdog()
|
|
@@ -817,7 +855,7 @@ def main():
|
|
|
817
855
|
|
|
818
856
|
# kill 命令
|
|
819
857
|
kill_parser = subparsers.add_parser("kill", help="终止会话")
|
|
820
|
-
kill_parser.add_argument("name", help="
|
|
858
|
+
kill_parser.add_argument("name", help="会话名称或 PID")
|
|
821
859
|
kill_parser.set_defaults(func=cmd_kill)
|
|
822
860
|
|
|
823
861
|
# status 命令
|
package/utils/session.py
CHANGED
|
@@ -519,6 +519,47 @@ def save_lark_status(pid: int):
|
|
|
519
519
|
status_file.write_text(json.dumps(status_data))
|
|
520
520
|
|
|
521
521
|
|
|
522
|
+
def kill_all_lark_processes(exclude_pid: int = None) -> list:
|
|
523
|
+
"""查找并杀死所有 lark_client 相关进程(排除指定 PID)
|
|
524
|
+
|
|
525
|
+
返回被杀死的 PID 列表
|
|
526
|
+
"""
|
|
527
|
+
import signal
|
|
528
|
+
import time
|
|
529
|
+
killed = []
|
|
530
|
+
|
|
531
|
+
# 查找所有 lark_client.main 相关进程
|
|
532
|
+
try:
|
|
533
|
+
result = subprocess.run(
|
|
534
|
+
["pgrep", "-f", "lark_client.main"],
|
|
535
|
+
capture_output=True, text=True, timeout=5
|
|
536
|
+
)
|
|
537
|
+
pids = [int(p) for p in result.stdout.strip().split('\n') if p.strip()]
|
|
538
|
+
except Exception:
|
|
539
|
+
return killed
|
|
540
|
+
|
|
541
|
+
for pid in pids:
|
|
542
|
+
if pid == exclude_pid or pid == os.getpid():
|
|
543
|
+
continue
|
|
544
|
+
try:
|
|
545
|
+
os.kill(pid, signal.SIGTERM)
|
|
546
|
+
killed.append(pid)
|
|
547
|
+
except (ProcessLookupError, PermissionError):
|
|
548
|
+
pass
|
|
549
|
+
|
|
550
|
+
# 等待退出,必要时 SIGKILL
|
|
551
|
+
if killed:
|
|
552
|
+
time.sleep(1)
|
|
553
|
+
for pid in killed:
|
|
554
|
+
try:
|
|
555
|
+
os.kill(pid, 0) # 检查是否还活着
|
|
556
|
+
os.kill(pid, signal.SIGKILL)
|
|
557
|
+
except (ProcessLookupError, PermissionError):
|
|
558
|
+
pass
|
|
559
|
+
|
|
560
|
+
return killed
|
|
561
|
+
|
|
562
|
+
|
|
522
563
|
def cleanup_lark():
|
|
523
564
|
"""清理飞书客户端残留文件"""
|
|
524
565
|
pid_file = get_lark_pid_file()
|