remote-claude 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,250 @@
1
+ """
2
+ 飞书卡片服务 - 支持创建和实时更新卡片
3
+ """
4
+
5
+ import json
6
+ import logging
7
+ import time
8
+ import uuid
9
+ from typing import Dict, Any, Optional
10
+ from dataclasses import dataclass, field
11
+
12
+ import lark_oapi as lark
13
+
14
+ logger = logging.getLogger('CardService')
15
+ from lark_oapi.api.im.v1 import (
16
+ CreateMessageRequest, CreateMessageRequestBody,
17
+ )
18
+ from lark_oapi.api.cardkit.v1 import (
19
+ CreateCardRequest, CreateCardRequestBody,
20
+ UpdateCardRequest, UpdateCardRequestBody, Card
21
+ )
22
+
23
+ from . import config
24
+
25
+ import sys as _sys
26
+ _sys.path.insert(0, str(__import__('pathlib').Path(__file__).parent.parent))
27
+ try:
28
+ from stats import track as _track_stats
29
+ except Exception:
30
+ def _track_stats(*args, **kwargs): pass
31
+
32
+
33
+ @dataclass
34
+ class CardState:
35
+ """卡片状态"""
36
+ card_id: str
37
+ message_id: Optional[str] = None
38
+ sequence: int = 0
39
+ last_update: float = field(default_factory=time.time)
40
+
41
+
42
+ class CardService:
43
+ """飞书卡片服务"""
44
+
45
+ def __init__(self):
46
+ self.client: Optional[lark.Client] = None
47
+ self._init_client()
48
+ # chat_id -> CardState
49
+ self._active_cards: Dict[str, CardState] = {}
50
+ # message_id -> CardState(反查,用于按钮点击就地更新)
51
+ self._cards_by_message_id: Dict[str, CardState] = {}
52
+
53
+ def _init_client(self):
54
+ """初始化飞书客户端"""
55
+ if config.FEISHU_APP_ID and config.FEISHU_APP_SECRET:
56
+ self.client = lark.Client.builder() \
57
+ .app_id(config.FEISHU_APP_ID) \
58
+ .app_secret(config.FEISHU_APP_SECRET) \
59
+ .build()
60
+
61
+ async def create_card(self, card_content: Dict[str, Any]) -> Optional[str]:
62
+ """创建卡片实体,返回 card_id(失败自动重试 1 次)"""
63
+ if not self.client:
64
+ print("[CardService] 客户端未初始化")
65
+ return None
66
+
67
+ import asyncio
68
+
69
+ for attempt in range(2):
70
+ try:
71
+ request = CreateCardRequest.builder() \
72
+ .request_body(
73
+ CreateCardRequestBody.builder()
74
+ .type("card_json")
75
+ .data(json.dumps(card_content, ensure_ascii=False))
76
+ .build()
77
+ ) \
78
+ .build()
79
+
80
+ response = await asyncio.to_thread(
81
+ self.client.cardkit.v1.card.create, request
82
+ )
83
+
84
+ if response.success():
85
+ card_id = getattr(getattr(response, "data", None), "card_id", None)
86
+ return card_id
87
+ else:
88
+ logger.warning(f"创建卡片失败(attempt={attempt+1}): code={response.code} msg={response.msg}")
89
+ except Exception as e:
90
+ logger.error(f"创建卡片异常(attempt={attempt+1}): {e}")
91
+
92
+ if attempt == 0:
93
+ await asyncio.sleep(1)
94
+
95
+ _track_stats('error', 'card_api', detail='create_card')
96
+ return None
97
+
98
+ async def send_card(self, chat_id: str, card_id: str) -> Optional[str]:
99
+ """发送卡片消息,返回 message_id"""
100
+ if not self.client:
101
+ return None
102
+
103
+ try:
104
+ import asyncio
105
+
106
+ card_content = {
107
+ "type": "card",
108
+ "data": {"card_id": card_id}
109
+ }
110
+
111
+ request = CreateMessageRequest.builder() \
112
+ .receive_id_type("chat_id") \
113
+ .request_body(
114
+ CreateMessageRequestBody.builder()
115
+ .receive_id(chat_id)
116
+ .msg_type("interactive")
117
+ .content(json.dumps(card_content))
118
+ .build()
119
+ ) \
120
+ .build()
121
+
122
+ response = await asyncio.to_thread(
123
+ self.client.im.v1.message.create, request
124
+ )
125
+
126
+ if response.success():
127
+ message_id = getattr(getattr(response, "data", None), "message_id", None)
128
+ return message_id
129
+ else:
130
+ logger.warning(f"发送卡片失败: code={response.code} msg={response.msg}")
131
+ return None
132
+ except Exception as e:
133
+ logger.error(f"发送卡片异常: {e}")
134
+ return None
135
+
136
+ async def create_and_send_card(
137
+ self, chat_id: str, card_content: Dict[str, Any]
138
+ ) -> Optional[str]:
139
+ """创建卡片并发送,内部维护 message_id 反查索引,返回 message_id"""
140
+ card_id = await self.create_card(card_content)
141
+ if not card_id:
142
+ return None
143
+ message_id = await self.send_card(chat_id, card_id)
144
+ if message_id:
145
+ state = CardState(card_id=card_id, message_id=message_id)
146
+ self._cards_by_message_id[message_id] = state
147
+ logger.info(f"已记录卡片: msg={message_id}, card={card_id}")
148
+ return message_id
149
+
150
+ async def update_card_by_message_id(
151
+ self, message_id: str, card_content: Dict[str, Any]
152
+ ) -> bool:
153
+ """按 message_id 就地更新卡片内容(通过 card_id 反查使用 CardKit update)"""
154
+ state = self._cards_by_message_id.get(message_id)
155
+ if not state:
156
+ logger.warning(f"未找到 message_id 对应的卡片状态: {message_id}")
157
+ return False
158
+ state.sequence += 1
159
+ return await self.update_card(state.card_id, state.sequence, card_content)
160
+
161
+ async def update_card(self, card_id: str, sequence: int, card_content: Dict[str, Any]) -> bool:
162
+ """更新卡片内容(失败自动重试 1 次)"""
163
+ if not self.client:
164
+ return False
165
+
166
+ import asyncio
167
+
168
+ for attempt in range(2):
169
+ try:
170
+ update_uuid = f"{int(time.time() * 1000)}-{uuid.uuid4()}"
171
+
172
+ request = UpdateCardRequest.builder() \
173
+ .card_id(card_id) \
174
+ .request_body(
175
+ UpdateCardRequestBody.builder()
176
+ .uuid(update_uuid)
177
+ .sequence(sequence)
178
+ .card(
179
+ Card.builder()
180
+ .type("card_json")
181
+ .data(json.dumps(card_content, ensure_ascii=False))
182
+ .build()
183
+ )
184
+ .build()
185
+ ) \
186
+ .build()
187
+
188
+ response = await asyncio.to_thread(
189
+ self.client.cardkit.v1.card.update, request
190
+ )
191
+
192
+ if response.success():
193
+ return True
194
+ else:
195
+ logger.warning(f"更新卡片失败(attempt={attempt+1}): card_id={card_id} seq={sequence} code={response.code} msg={response.msg}")
196
+ except Exception as e:
197
+ logger.error(f"更新卡片异常(attempt={attempt+1}): card_id={card_id} seq={sequence} error={e}")
198
+
199
+ if attempt == 0:
200
+ await asyncio.sleep(1)
201
+
202
+ _track_stats('error', 'card_api', detail='update_card')
203
+ return False
204
+
205
+ async def send_text(self, chat_id: str, text: str):
206
+ """发送纯文本消息(备用)"""
207
+ if not self.client:
208
+ print(f"[Lark] 消息: {text}")
209
+ return
210
+
211
+ try:
212
+ import asyncio
213
+
214
+ request = CreateMessageRequest.builder() \
215
+ .receive_id_type("chat_id") \
216
+ .request_body(
217
+ CreateMessageRequestBody.builder()
218
+ .receive_id(chat_id)
219
+ .msg_type("text")
220
+ .content(json.dumps({"text": text}))
221
+ .build()
222
+ ) \
223
+ .build()
224
+
225
+ response = await asyncio.to_thread(
226
+ self.client.im.v1.message.create, request
227
+ )
228
+
229
+ if not response.success():
230
+ logger.warning(f"发送文本失败: code={response.code} msg={response.msg}")
231
+ except Exception as e:
232
+ logger.error(f"发送文本异常: {e}")
233
+
234
+ # 管理活跃卡片的方法
235
+ def get_active_card(self, chat_id: str) -> Optional[CardState]:
236
+ """获取聊天的活跃卡片"""
237
+ return self._active_cards.get(chat_id)
238
+
239
+ def set_active_card(self, chat_id: str, card_state: CardState):
240
+ """设置聊天的活跃卡片"""
241
+ self._active_cards[chat_id] = card_state
242
+
243
+ def clear_active_card(self, chat_id: str):
244
+ """清除聊天的活跃卡片"""
245
+ if chat_id in self._active_cards:
246
+ del self._active_cards[chat_id]
247
+
248
+
249
+ # 全局实例
250
+ card_service = CardService()
@@ -0,0 +1,22 @@
1
+ """
2
+ Lark 客户端配置
3
+ """
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from dotenv import load_dotenv
8
+
9
+ # 加载 .env 文件
10
+ BASE_DIR = Path(__file__).resolve().parent.parent
11
+ load_dotenv(BASE_DIR / ".env")
12
+
13
+ # 飞书应用配置
14
+ FEISHU_APP_ID = os.getenv("FEISHU_APP_ID", "")
15
+ FEISHU_APP_SECRET = os.getenv("FEISHU_APP_SECRET", "")
16
+
17
+ # 用户白名单(逗号分隔的 open_id 列表)
18
+ ALLOWED_USERS = os.getenv("ALLOWED_USERS", "").split(",")
19
+ ENABLE_USER_WHITELIST = os.getenv("ENABLE_USER_WHITELIST", "false").lower() == "true"
20
+
21
+ # 机器人名称(用于群聊命名)
22
+ BOT_NAME = os.getenv("BOT_NAME", "Claude")