remote-claude 0.2.7 → 0.2.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/.env.example CHANGED
@@ -18,3 +18,7 @@ ALLOWED_USERS=ou_xxxxx,ou_yyyyy
18
18
  # 支持多词命令,如:ccr code、/usr/local/bin/claude
19
19
  # CLAUDE_COMMAND=claude
20
20
 
21
+ # 流式卡片配置(可选)
22
+ # 单张卡片最多显示的 block 数量,超限自动冻结并创建新卡片(默认 50)
23
+ # MAX_CARD_BLOCKS=50
24
+
package/init.sh CHANGED
@@ -1,7 +1,5 @@
1
1
  #!/bin/bash
2
2
 
3
- set -e
4
-
5
3
  # 颜色定义
6
4
  RED=$'\033[0;31m'
7
5
  GREEN=$'\033[0;32m'
@@ -128,7 +126,7 @@ check_uv() {
128
126
  # 方式五:brew(macOS 备用)
129
127
  if ! command -v uv &> /dev/null && [[ "$OS" == "Darwin" ]] && command -v brew &> /dev/null; then
130
128
  print_warning "尝试 brew install uv..."
131
- brew install uv
129
+ brew install uv 2>/dev/null || true
132
130
  fi
133
131
 
134
132
  if command -v uv &> /dev/null; then
@@ -181,18 +179,18 @@ check_tmux() {
181
179
  fi
182
180
  print_success "Homebrew 安装成功"
183
181
  fi
184
- brew install tmux
182
+ brew install tmux 2>/dev/null || true
185
183
  elif [[ "$OS" == "Linux" ]]; then
186
184
  if command -v apt-get &> /dev/null; then
187
- sudo apt-get update && sudo apt-get install -y tmux
185
+ sudo apt-get update && sudo apt-get install -y tmux || true
188
186
  elif command -v yum &> /dev/null; then
189
- sudo yum install -y tmux
187
+ sudo yum install -y tmux || true
190
188
  elif command -v pacman &> /dev/null; then
191
- sudo pacman -Sy --noconfirm tmux
189
+ sudo pacman -Sy --noconfirm tmux || true
192
190
  elif command -v apk &> /dev/null; then
193
- sudo apk add --no-cache tmux
191
+ sudo apk add --no-cache tmux || true
194
192
  elif command -v zypper &> /dev/null; then
195
- sudo zypper install -y tmux
193
+ sudo zypper install -y tmux || true
196
194
  else
197
195
  print_warning "无法识别包管理器,尝试从源码编译 tmux..."
198
196
  install_tmux_from_source
@@ -212,10 +210,10 @@ check_tmux() {
212
210
  if [[ "$OS" == "Darwin" ]]; then
213
211
  brew install libevent ncurses pkg-config bison 2>/dev/null || true
214
212
  elif command -v apt-get &> /dev/null; then
215
- sudo apt-get install -y build-essential libevent-dev libncurses5-dev libncursesw5-dev bison pkg-config
213
+ sudo apt-get install -y build-essential libevent-dev libncurses5-dev libncursesw5-dev bison pkg-config || true
216
214
  elif command -v yum &> /dev/null; then
217
- sudo yum groupinstall -y "Development Tools"
218
- sudo yum install -y libevent-devel ncurses-devel bison
215
+ sudo yum groupinstall -y "Development Tools" || true
216
+ sudo yum install -y libevent-devel ncurses-devel bison || true
219
217
  fi
220
218
 
221
219
  # 确定安装前缀
@@ -249,9 +247,9 @@ check_tmux() {
249
247
  fi
250
248
 
251
249
  if [[ "$PREFIX" == "/usr/local" ]]; then
252
- sudo make -C "$SRC_DIR" install
250
+ sudo make -C "$SRC_DIR" install || { WARNINGS+=("tmux make install 失败,请手动安装 tmux ${REQUIRED_MAJOR}.${REQUIRED_MINOR}+"); return; }
253
251
  else
254
- make -C "$SRC_DIR" install
252
+ make -C "$SRC_DIR" install || { WARNINGS+=("tmux make install 失败,请手动安装 tmux ${REQUIRED_MAJOR}.${REQUIRED_MINOR}+"); return; }
255
253
  # 若 $HOME/.local/bin 不在 PATH 中,自动写入 shell 配置
256
254
  if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
257
255
  export PATH="$HOME/.local/bin:$PATH"
@@ -363,9 +361,9 @@ install_dependencies() {
363
361
 
364
362
  print_info "正在通过 uv 同步依赖..."
365
363
  if $NPM_MODE; then
366
- uv sync --frozen
364
+ uv sync || { print_error "依赖安装失败"; exit 1; }
367
365
  else
368
- uv sync
366
+ uv sync || { print_error "依赖安装失败"; exit 1; }
369
367
  fi
370
368
 
371
369
  print_success "依赖安装完成"
@@ -451,7 +449,7 @@ configure_shell() {
451
449
  print_header "安装快捷命令"
452
450
 
453
451
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
454
- chmod +x "$SCRIPT_DIR/bin/cla" "$SCRIPT_DIR/bin/cl" "$SCRIPT_DIR/bin/remote-claude"
452
+ chmod +x "$SCRIPT_DIR/bin/cla" "$SCRIPT_DIR/bin/cl" "$SCRIPT_DIR/bin/remote-claude" 2>/dev/null || true
455
453
 
456
454
  # 优先 /usr/local/bin,权限不够则选 ~/bin 或 ~/.local/bin 中已在 PATH 里的
457
455
  BIN_DIR="/usr/local/bin"
@@ -479,12 +477,12 @@ configure_shell() {
479
477
  fi
480
478
  fi
481
479
  mkdir -p "$BIN_DIR"
482
- ln -sf "$SCRIPT_DIR/bin/cla" "$BIN_DIR/cla"
483
- ln -sf "$SCRIPT_DIR/bin/cl" "$BIN_DIR/cl"
484
- ln -sf "$SCRIPT_DIR/bin/remote-claude" "$BIN_DIR/remote-claude"
480
+ ln -sf "$SCRIPT_DIR/bin/cla" "$BIN_DIR/cla" 2>/dev/null || true
481
+ ln -sf "$SCRIPT_DIR/bin/cl" "$BIN_DIR/cl" 2>/dev/null || true
482
+ ln -sf "$SCRIPT_DIR/bin/remote-claude" "$BIN_DIR/remote-claude" 2>/dev/null || true
485
483
  else
486
- ln -sf "$SCRIPT_DIR/bin/cl" "$BIN_DIR/cl"
487
- ln -sf "$SCRIPT_DIR/bin/remote-claude" "$BIN_DIR/remote-claude"
484
+ ln -sf "$SCRIPT_DIR/bin/cl" "$BIN_DIR/cl" 2>/dev/null || true
485
+ ln -sf "$SCRIPT_DIR/bin/remote-claude" "$BIN_DIR/remote-claude" 2>/dev/null || true
488
486
  fi
489
487
 
490
488
  print_success "已安装 cla、cl 和 remote-claude 到 $BIN_DIR"
@@ -525,7 +523,7 @@ restart_lark_client() {
525
523
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
526
524
  print_info "正在重启飞书客户端..."
527
525
  cd "$SCRIPT_DIR"
528
- uv run python3 remote_claude.py lark restart
526
+ uv run python3 remote_claude.py lark restart || { WARNINGS+=("飞书客户端重启失败,请手动运行: uv run python3 remote_claude.py lark restart"); return; }
529
527
  print_success "飞书客户端已重启"
530
528
  }
531
529
 
@@ -17,6 +17,12 @@ from typing import Dict, Any, List, Optional
17
17
 
18
18
  _cb_logger = logging.getLogger('CardBuilder')
19
19
 
20
+ # CLI 类型 → 显示名称映射(用于卡片标题中的"就绪"文案)
21
+ CLI_NAMES: Dict[str, str] = {
22
+ "claude": "Claude",
23
+ "codex": "Codex",
24
+ }
25
+
20
26
  # 版本号:从 package.json 读取,import 时只读一次
21
27
  try:
22
28
  _pkg = _pl.Path(__file__).parent.parent / "package.json"
@@ -539,6 +545,17 @@ def _render_block_colored(block_dict: dict) -> Optional[str]:
539
545
  parts.append("🔐 权限确认")
540
546
  return "\n".join(parts)
541
547
 
548
+ elif typ == "SystemBlock":
549
+ content = block_dict.get("content", "")
550
+ if not content:
551
+ return None
552
+ ansi_content = block_dict.get("ansi_content", "")
553
+ ansi_ind = block_dict.get("ansi_indicator", "")
554
+ indicator = block_dict.get("indicator", "✻")
555
+ ind_md = _ansi_to_lark_md(ansi_ind) if ansi_ind else _escape_md(indicator)
556
+ content_md = _ansi_to_lark_md(ansi_content) if ansi_content else _escape_md(content)
557
+ return f"{ind_md} {content_md}"
558
+
542
559
  return None
543
560
 
544
561
 
@@ -565,6 +582,7 @@ def _determine_header(
565
582
  is_frozen: bool,
566
583
  option_block: Optional[dict] = None,
567
584
  disconnected: bool = False,
585
+ cli_type: str = "claude",
568
586
  ) -> tuple:
569
587
  """确定卡片标题和颜色模板,返回 (title, template)"""
570
588
  if disconnected:
@@ -601,7 +619,8 @@ def _determine_header(
601
619
  return "🔐 等待权限确认", "red"
602
620
  return "🤔 等待选择", "blue"
603
621
 
604
- return "✅ Claude 就绪", "green"
622
+ cli_name = CLI_NAMES.get(cli_type, "Claude")
623
+ return f"✅ {cli_name} 就绪", "green"
605
624
 
606
625
 
607
626
  def _extract_buttons(blocks: List[dict], option_block: Optional[dict] = None) -> List[Dict[str, str]]:
@@ -626,6 +645,7 @@ def build_stream_card(
626
645
  option_block: Optional[dict] = None,
627
646
  session_name: Optional[str] = None,
628
647
  disconnected: bool = False,
648
+ cli_type: str = "claude",
629
649
  ) -> Dict[str, Any]:
630
650
  """从共享内存 blocks 流构建飞书卡片
631
651
 
@@ -637,7 +657,8 @@ def build_stream_card(
637
657
  """
638
658
  title, template = _determine_header(
639
659
  blocks, status_line, bottom_bar, is_frozen,
640
- option_block=option_block, disconnected=disconnected
660
+ option_block=option_block, disconnected=disconnected,
661
+ cli_type=cli_type,
641
662
  )
642
663
 
643
664
  # === 第一层:内容区 ===
@@ -749,12 +770,17 @@ def build_stream_card(
749
770
 
750
771
  # === 辅助卡片(保留不变)===
751
772
 
752
- def _build_session_list_elements(sessions: List[Dict], current_session: Optional[str], session_groups: Optional[Dict[str, str]]) -> List[Dict]:
773
+ def _build_session_list_elements(sessions: List[Dict], current_session: Optional[str], session_groups: Optional[Dict[str, str]], page: int = 0) -> List[Dict]:
753
774
  """构建会话列表元素(供 build_menu_card 复用)"""
754
775
  import os
755
776
  elements = []
756
777
  if sessions:
757
- for s in sessions:
778
+ PER_PAGE = 8
779
+ total = len(sessions)
780
+ total_pages = max(1, (total + PER_PAGE - 1) // PER_PAGE)
781
+ page = max(0, min(page, total_pages - 1))
782
+ shown = sessions[page * PER_PAGE : (page + 1) * PER_PAGE]
783
+ for s in shown:
758
784
  name = s["name"]
759
785
  cwd = s.get("cwd", "")
760
786
  start_time = s.get("start_time", "")
@@ -824,6 +850,18 @@ def _build_session_list_elements(sessions: List[Dict], current_session: Optional
824
850
  "action": "list_disband_group", "session": name
825
851
  }}]
826
852
  })
853
+ right_buttons.append({
854
+ "tag": "button",
855
+ "text": {"tag": "plain_text", "content": "🗑️ 关闭"},
856
+ "type": "danger",
857
+ "confirm": {
858
+ "title": {"tag": "plain_text", "content": "确认关闭会话"},
859
+ "text": {"tag": "plain_text", "content": f"确定要关闭「{name}」吗?此操作不可撤销。"}
860
+ },
861
+ "behaviors": [{"type": "callback", "value": {
862
+ "action": "list_kill", "session": name
863
+ }}]
864
+ })
827
865
  elements.append({
828
866
  "tag": "column_set",
829
867
  "flex_mode": "none",
@@ -846,6 +884,41 @@ def _build_session_list_elements(sessions: List[Dict], current_session: Optional
846
884
 
847
885
  if elements and elements[-1].get("tag") == "hr":
848
886
  elements.pop()
887
+
888
+ if total > PER_PAGE:
889
+ prev_disabled = page == 0
890
+ next_disabled = page >= total_pages - 1
891
+ prev_btn = {
892
+ "tag": "button",
893
+ "text": {"tag": "plain_text", "content": "⬅ 上一页"},
894
+ "type": "default",
895
+ **({"disabled": True} if prev_disabled else {"behaviors": [{"type": "callback", "value": {
896
+ "action": "menu_page", "page": page - 1
897
+ }}]})
898
+ }
899
+ next_btn = {
900
+ "tag": "button",
901
+ "text": {"tag": "plain_text", "content": "下一页 ➡"},
902
+ "type": "default",
903
+ **({"disabled": True} if next_disabled else {"behaviors": [{"type": "callback", "value": {
904
+ "action": "menu_page", "page": page + 1
905
+ }}]})
906
+ }
907
+ elements.append({"tag": "hr"})
908
+ elements.append({
909
+ "tag": "column_set",
910
+ "flex_mode": "none",
911
+ "horizontal_spacing": "small",
912
+ "columns": [
913
+ {"tag": "column", "width": "weighted", "weight": 1, "elements": [{"tag": "markdown", "content": " "}]},
914
+ {"tag": "column", "width": "auto", "elements": [prev_btn]},
915
+ {"tag": "column", "width": "auto", "vertical_align": "center", "elements": [
916
+ {"tag": "markdown", "content": f"第 {page + 1}/{total_pages} 页"}
917
+ ]},
918
+ {"tag": "column", "width": "auto", "elements": [next_btn]},
919
+ {"tag": "column", "width": "weighted", "weight": 1, "elements": [{"tag": "markdown", "content": " "}]},
920
+ ]
921
+ })
849
922
  else:
850
923
  elements.append({
851
924
  "tag": "markdown",
@@ -1001,41 +1074,38 @@ def build_dir_card(target, entries: List[Dict], sessions: List[Dict], tree: bool
1001
1074
  elements.append({"tag": "markdown", "content": f"{indent}{icon} {name}"})
1002
1075
 
1003
1076
  if not tree and total > PER_PAGE:
1004
- page_cols = []
1005
- if page > 0:
1006
- page_cols.append({
1007
- "tag": "column",
1008
- "width": "auto",
1009
- "elements": [{
1010
- "tag": "button",
1011
- "text": {"tag": "plain_text", "content": "⬅️ 上一页"},
1012
- "type": "default",
1013
- "behaviors": [{"type": "callback", "value": {
1014
- "action": "dir_page", "path": target_str, "page": page - 1
1015
- }}]
1016
- }]
1017
- })
1018
- page_cols.append({
1019
- "tag": "column",
1020
- "width": "weighted",
1021
- "weight": 2,
1022
- "vertical_align": "center",
1023
- "elements": [{"tag": "markdown", "content": f"第 {page + 1}/{total_pages} 页"}]
1077
+ prev_disabled = page == 0
1078
+ next_disabled = page >= total_pages - 1
1079
+ prev_btn = {
1080
+ "tag": "button",
1081
+ "text": {"tag": "plain_text", "content": "⬅ 上一页"},
1082
+ "type": "default",
1083
+ **({"disabled": True} if prev_disabled else {"behaviors": [{"type": "callback", "value": {
1084
+ "action": "dir_page", "path": target_str, "page": page - 1
1085
+ }}]})
1086
+ }
1087
+ next_btn = {
1088
+ "tag": "button",
1089
+ "text": {"tag": "plain_text", "content": "下一页 ➡"},
1090
+ "type": "default",
1091
+ **({"disabled": True} if next_disabled else {"behaviors": [{"type": "callback", "value": {
1092
+ "action": "dir_page", "path": target_str, "page": page + 1
1093
+ }}]})
1094
+ }
1095
+ elements.append({
1096
+ "tag": "column_set",
1097
+ "flex_mode": "none",
1098
+ "horizontal_spacing": "small",
1099
+ "columns": [
1100
+ {"tag": "column", "width": "weighted", "weight": 1, "elements": [{"tag": "markdown", "content": " "}]},
1101
+ {"tag": "column", "width": "auto", "elements": [prev_btn]},
1102
+ {"tag": "column", "width": "auto", "vertical_align": "center", "elements": [
1103
+ {"tag": "markdown", "content": f"第 {page + 1}/{total_pages} 页"}
1104
+ ]},
1105
+ {"tag": "column", "width": "auto", "elements": [next_btn]},
1106
+ {"tag": "column", "width": "weighted", "weight": 1, "elements": [{"tag": "markdown", "content": " "}]},
1107
+ ]
1024
1108
  })
1025
- if page < total_pages - 1:
1026
- page_cols.append({
1027
- "tag": "column",
1028
- "width": "auto",
1029
- "elements": [{
1030
- "tag": "button",
1031
- "text": {"tag": "plain_text", "content": "下一页 ➡️"},
1032
- "type": "default",
1033
- "behaviors": [{"type": "callback", "value": {
1034
- "action": "dir_page", "path": target_str, "page": page + 1
1035
- }}]
1036
- }]
1037
- })
1038
- elements.append({"tag": "column_set", "flex_mode": "none", "columns": page_cols})
1039
1109
 
1040
1110
  elements.append({"tag": "hr"})
1041
1111
  elements.append(_build_menu_button_only())
@@ -1124,12 +1194,13 @@ def build_session_closed_card(session_name: str) -> Dict[str, Any]:
1124
1194
 
1125
1195
 
1126
1196
  def build_menu_card(sessions: List[Dict], current_session: Optional[str] = None,
1127
- session_groups: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
1197
+ session_groups: Optional[Dict[str, str]] = None, page: int = 0) -> Dict[str, Any]:
1128
1198
  """构建快捷操作菜单卡片(/menu 和 /list 共用):内嵌会话列表 + 快捷操作"""
1129
1199
  elements = []
1130
1200
 
1131
1201
  elements.append({"tag": "markdown", "content": "**会话管理**"})
1132
- elements.extend(_build_session_list_elements(sessions, current_session, session_groups))
1202
+ elements.append({"tag": "hr"})
1203
+ elements.extend(_build_session_list_elements(sessions, current_session, session_groups, page=page))
1133
1204
 
1134
1205
  elements.append({"tag": "hr"})
1135
1206
  elements.append({"tag": "markdown", "content": "**快捷操作**"})
@@ -1159,6 +1230,17 @@ def build_menu_card(sessions: List[Dict], current_session: Optional[str] = None,
1159
1230
  "behaviors": [{"type": "callback", "value": {"action": "menu_tree"}}]
1160
1231
  }]
1161
1232
  },
1233
+ {
1234
+ "tag": "column",
1235
+ "width": "weighted",
1236
+ "weight": 1,
1237
+ "elements": [{
1238
+ "tag": "button",
1239
+ "text": {"tag": "plain_text", "content": "🔄 刷新"},
1240
+ "type": "default",
1241
+ "behaviors": [{"type": "callback", "value": {"action": "menu_open"}}]
1242
+ }]
1243
+ },
1162
1244
  ]
1163
1245
  })
1164
1246
 
@@ -35,3 +35,6 @@ BOT_NAME = os.getenv("BOT_NAME", "Claude")
35
35
 
36
36
  # 群聊名称前缀(格式:{GROUP_NAME_PREFIX}{dir}-{HH-MM})
37
37
  GROUP_NAME_PREFIX = os.getenv("GROUP_NAME_PREFIX", "【Remote-Claude】")
38
+
39
+ # 流式卡片配置
40
+ MAX_CARD_BLOCKS = int(os.getenv("MAX_CARD_BLOCKS", "50"))
@@ -103,7 +103,13 @@ class LarkHandler:
103
103
  except Exception as e:
104
104
  logger.warning(f"保存群聊 ID 失败: {e}")
105
105
 
106
- def _remove_binding_by_chat(self, chat_id: str):
106
+ def _remove_binding_by_chat(self, chat_id: str, force: bool = False):
107
+ """移除 chat_id 的绑定。
108
+ 群聊绑定默认不移除(避免断开后无法解散群);
109
+ force=True 时强制移除(用于会话终止/解散群场景)。
110
+ """
111
+ if not force and chat_id in self._group_chat_ids:
112
+ return
107
113
  self._chat_bindings.pop(chat_id, None)
108
114
  self._save_chat_bindings()
109
115
 
@@ -395,7 +401,8 @@ class LarkHandler:
395
401
  logger.error(f"启动并创建群聊失败: {e}")
396
402
  await card_service.send_text(chat_id, f"操作失败:{e}")
397
403
 
398
- async def _cmd_kill(self, user_id: str, chat_id: str, args: str):
404
+ async def _cmd_kill(self, user_id: str, chat_id: str, args: str,
405
+ message_id: Optional[str] = None):
399
406
  """终止会话"""
400
407
  from utils.session import cleanup_session, tmux_session_exists, tmux_kill_session
401
408
 
@@ -409,6 +416,13 @@ class LarkHandler:
409
416
  await card_service.send_text(chat_id, f"错误: 会话 '{session_name}' 不存在")
410
417
  return
411
418
 
419
+ # 解散绑定该会话的专属群聊(必须在断开连接之前,否则 _chat_bindings 已被清除)
420
+ for cid in list(self._group_chat_ids):
421
+ if self._chat_bindings.get(cid) == session_name:
422
+ ok, err = await self._disband_group_via_api(cid)
423
+ if not ok:
424
+ logger.warning(f"关闭会话时解散群 {cid} 失败: {err}")
425
+
412
426
  # 断开所有连接到此会话的 chat
413
427
  for cid, sname in list(self._chat_sessions.items()):
414
428
  if sname == session_name:
@@ -416,13 +430,21 @@ class LarkHandler:
416
430
  if active_slice:
417
431
  await self._update_card_disconnected(cid, sname, active_slice)
418
432
  await self._detach(cid)
419
- self._remove_binding_by_chat(cid)
433
+ self._remove_binding_by_chat(cid, force=True)
434
+
435
+ # 清理所有残留绑定(包括已断开的群聊,其绑定在断开时被保留)
436
+ for cid in [c for c, s in list(self._chat_bindings.items()) if s == session_name]:
437
+ self._group_chat_ids.discard(cid)
438
+ self._chat_bindings.pop(cid, None)
439
+ self._save_chat_bindings()
440
+ self._save_group_chat_ids()
420
441
 
421
442
  if tmux_session_exists(session_name):
422
443
  tmux_kill_session(session_name)
423
444
  cleanup_session(session_name)
424
445
 
425
446
  await card_service.send_text(chat_id, f"✅ 会话 '{session_name}' 已终止")
447
+ await self._cmd_list(user_id, chat_id, message_id=message_id)
426
448
 
427
449
  async def _handle_list_detach(self, user_id: str, chat_id: str,
428
450
  message_id: Optional[str] = None):
@@ -542,7 +564,7 @@ class LarkHandler:
542
564
  await self._send_or_update_card(chat_id, card, message_id)
543
565
 
544
566
  async def _cmd_menu(self, user_id: str, chat_id: str,
545
- message_id: Optional[str] = None):
567
+ message_id: Optional[str] = None, page: int = 0):
546
568
  """显示快捷操作菜单(内嵌会话列表)"""
547
569
  sessions = list_active_sessions()
548
570
  current = self._chat_sessions.get(chat_id)
@@ -551,7 +573,7 @@ class LarkHandler:
551
573
  for cid in self._group_chat_ids
552
574
  if cid in self._chat_bindings
553
575
  }
554
- card = build_menu_card(sessions, current_session=current, session_groups=session_groups)
576
+ card = build_menu_card(sessions, current_session=current, session_groups=session_groups, page=page)
555
577
  await self._send_or_update_card(chat_id, card, message_id)
556
578
 
557
579
  async def _cmd_ls(self, user_id: str, chat_id: str, args: str,
@@ -665,28 +687,14 @@ class LarkHandler:
665
687
  # 立即 attach,让新群即刻开始接收 Claude 输出
666
688
  await self._attach(group_chat_id, session_name)
667
689
 
668
- await card_service.send_text(
669
- chat_id,
670
- f"✅ 已创建专属群「{group_name}」并已连接\n"
671
- f"在群内直接发消息即可与 Claude 交互"
672
- )
673
690
  # 刷新会话列表卡片,使"创建群聊"按钮变为"进入群聊"
674
691
  await self._cmd_list(user_id, chat_id, message_id=message_id)
675
692
  except Exception as e:
676
693
  logger.error(f"创建群失败: {e}")
677
694
  await card_service.send_text(chat_id, f"创建群失败:{e}")
678
695
 
679
- async def _cmd_disband_group(self, user_id: str, chat_id: str, session_name: str,
680
- message_id: Optional[str] = None):
681
- """解散与指定会话绑定的专属群聊"""
682
- group_chat_id = next(
683
- (cid for cid, sname in self._chat_bindings.items() if sname == session_name and cid.startswith("oc_")),
684
- None
685
- )
686
- if not group_chat_id:
687
- await card_service.send_text(chat_id, f"会话 '{session_name}' 没有绑定群聊")
688
- return
689
-
696
+ async def _disband_group_via_api(self, group_chat_id: str) -> tuple:
697
+ """调用飞书 API 解散群聊,返回 (ok: bool, err_msg: str)"""
690
698
  import json as _json
691
699
  import urllib.request
692
700
  import urllib.error
@@ -701,9 +709,6 @@ class LarkHandler:
701
709
  ), timeout=10
702
710
  )
703
711
  token = _json.loads(token_resp.read())["tenant_access_token"]
704
-
705
- feishu_ok = False
706
- feishu_msg = ""
707
712
  try:
708
713
  disband_resp = urllib.request.urlopen(
709
714
  urllib.request.Request(
@@ -713,30 +718,46 @@ class LarkHandler:
713
718
  ), timeout=10
714
719
  )
715
720
  disband_data = _json.loads(disband_resp.read())
716
- feishu_ok = disband_data.get("code") == 0
717
- feishu_msg = disband_data.get("msg", "")
721
+ if disband_data.get("code") == 0:
722
+ return True, ""
723
+ return False, disband_data.get("msg", "")
718
724
  except urllib.error.HTTPError as e:
719
725
  err_body = e.read().decode("utf-8", errors="replace")
720
726
  try:
721
727
  err_data = _json.loads(err_body)
722
- feishu_ok = False
723
- feishu_msg = f"code={err_data.get('code')} {err_data.get('msg', '')}"
728
+ return False, f"code={err_data.get('code')} {err_data.get('msg', '')}"
724
729
  except Exception:
725
- feishu_ok = False
726
- feishu_msg = f"HTTP {e.code}"
730
+ return False, f"HTTP {e.code}"
731
+ except Exception as e:
732
+ return False, str(e)
733
+
734
+ async def _cmd_disband_group(self, user_id: str, chat_id: str, session_name: str,
735
+ message_id: Optional[str] = None):
736
+ """解散与指定会话绑定的专属群聊"""
737
+ group_chat_id = next(
738
+ (cid for cid, sname in self._chat_bindings.items() if sname == session_name and cid.startswith("oc_")),
739
+ None
740
+ )
741
+ if not group_chat_id:
742
+ await card_service.send_text(chat_id, f"会话 '{session_name}' 没有绑定群聊")
743
+ return
744
+
745
+ try:
746
+ feishu_ok, feishu_msg = await self._disband_group_via_api(group_chat_id)
747
+ if not feishu_ok:
727
748
  logger.error(f"解散群 API 失败: {feishu_msg}")
728
749
 
729
750
  # 无论 Feishu delete 是否成功,都清理本地绑定
730
- self._remove_binding_by_chat(group_chat_id)
731
751
  self._group_chat_ids.discard(group_chat_id)
732
752
  self._save_group_chat_ids()
753
+ self._remove_binding_by_chat(group_chat_id, force=True)
733
754
  await self._detach(group_chat_id)
734
755
 
735
- if feishu_ok:
736
- notice = "✅ 群聊已解散,绑定已解除"
737
- else:
738
- notice = f"⚠️ Feishu 群解散失败({feishu_msg}),已解除本地绑定。如需彻底解散请在飞书群内手动操作"
739
- await card_service.send_text(chat_id, notice)
756
+ if not feishu_ok:
757
+ await card_service.send_text(
758
+ chat_id,
759
+ f"⚠️ Feishu 群解散失败({feishu_msg}),已解除本地绑定。如需彻底解散请在飞书群内手动操作"
760
+ )
740
761
  await self._cmd_list(user_id, chat_id, message_id=message_id)
741
762
  except Exception as e:
742
763
  logger.error(f"解散群失败: {e}")
@@ -755,7 +776,9 @@ class LarkHandler:
755
776
  logger.info(f"自动恢复绑定: chat_id={chat_id[:8]}..., session={saved_session}")
756
777
  ok = await self._attach(chat_id, saved_session)
757
778
  if not ok:
758
- self._remove_binding_by_chat(chat_id)
779
+ self._group_chat_ids.discard(chat_id)
780
+ self._save_group_chat_ids()
781
+ self._remove_binding_by_chat(chat_id, force=True)
759
782
  await card_service.send_text(
760
783
  chat_id, f"会话 '{saved_session}' 已不存在,请重新 /attach"
761
784
  )
@@ -169,6 +169,13 @@ def handle_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerRespons
169
169
  asyncio.create_task(handler._cmd_disband_group(user_id, chat_id, session_name, message_id=message_id))
170
170
  return None
171
171
 
172
+ # 列表卡片:关闭会话
173
+ if action_type == "list_kill":
174
+ session_name = action_value.get("session", "")
175
+ print(f"[Lark] list_kill: session={session_name}")
176
+ asyncio.create_task(handler._cmd_kill(user_id, chat_id, session_name, message_id=message_id))
177
+ return None
178
+
172
179
  # 目录卡片:进入子目录(继续浏览,就地更新原卡片)
173
180
  if action_type == "dir_browse":
174
181
  path = action_value.get("path", "")
@@ -176,6 +183,13 @@ def handle_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerRespons
176
183
  asyncio.create_task(handler._cmd_ls(user_id, chat_id, path, message_id=message_id))
177
184
  return None
178
185
 
186
+ # 菜单卡片:会话列表翻页
187
+ if action_type == "menu_page":
188
+ page = int(action_value.get("page", 0))
189
+ print(f"[Lark] menu_page: page={page}")
190
+ asyncio.create_task(handler._cmd_menu(user_id, chat_id, message_id=message_id, page=page))
191
+ return None
192
+
179
193
  # 目录卡片:翻页
180
194
  if action_type == "dir_page":
181
195
  path = action_value.get("path", "")