python-codex 0.1.12__py3-none-any.whl → 0.1.13__py3-none-any.whl
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.
- pycodex/__init__.py +10 -8
- pycodex/agent.py +41 -17
- pycodex/cli.py +198 -145
- pycodex/compat.py +8 -4
- pycodex/feishu_card.py +693 -0
- pycodex/feishu_link.py +342 -0
- pycodex/model.py +89 -7
- pycodex/prompts/models.json +4 -4
- pycodex/protocol.py +17 -17
- pycodex/runtime.py +9 -14
- pycodex/runtime_services.py +45 -23
- pycodex/tools/apply_patch_tool.py +11 -12
- pycodex/tools/ipython_tool.py +144 -0
- pycodex/tools/unified_exec_manager.py +3 -0
- pycodex/utils/__init__.py +2 -13
- pycodex/utils/async_bridge.py +54 -0
- pycodex/utils/compactor.py +22 -9
- pycodex/utils/session_persist.py +57 -38
- pycodex/utils/toolcall_visualize.py +713 -0
- pycodex/utils/visualize.py +223 -861
- {python_codex-0.1.12.dist-info → python_codex-0.1.13.dist-info}/METADATA +1 -1
- {python_codex-0.1.12.dist-info → python_codex-0.1.13.dist-info}/RECORD +25 -20
- {python_codex-0.1.12.dist-info → python_codex-0.1.13.dist-info}/WHEEL +0 -0
- {python_codex-0.1.12.dist-info → python_codex-0.1.13.dist-info}/entry_points.txt +0 -0
- {python_codex-0.1.12.dist-info → python_codex-0.1.13.dist-info}/licenses/LICENSE +0 -0
pycodex/feishu_card.py
ADDED
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import time
|
|
5
|
+
import typing
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
FEISHU_API_BASE = "https://open.feishu.cn/open-apis"
|
|
10
|
+
FEISHU_DOMAIN = "https://open.feishu.cn"
|
|
11
|
+
FEISHU_REFRESH_TOKEN_FILE = "~/.codex/.feishu_refresh_token"
|
|
12
|
+
CARD_OUTPUT_LIMIT = 6500
|
|
13
|
+
CARD_OUTPUT_MODE_MARKDOWN = "markdown"
|
|
14
|
+
CARD_OUTPUT_MODE_CODE = "code"
|
|
15
|
+
CARD_CONTENT_ERROR_MARKER = "Failed to create card content"
|
|
16
|
+
LAST_TURN_PREFIX = "(*last turn)\n"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PycodexCard:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
app_id: "typing.Union[str, None]" = None,
|
|
23
|
+
app_secret: "typing.Union[str, None]" = None,
|
|
24
|
+
api_base: str = FEISHU_API_BASE,
|
|
25
|
+
domain: str = FEISHU_DOMAIN,
|
|
26
|
+
verification_token: "typing.Union[str, None]" = None,
|
|
27
|
+
encrypt_key: "typing.Union[str, None]" = None,
|
|
28
|
+
refresh_token: "typing.Union[str, None]" = None,
|
|
29
|
+
session: "typing.Union[requests.Session, None]" = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.app_id = app_id
|
|
32
|
+
self.app_secret = app_secret
|
|
33
|
+
self.api_base = api_base
|
|
34
|
+
self.domain = domain
|
|
35
|
+
self.verification_token = verification_token
|
|
36
|
+
self.encrypt_key = encrypt_key
|
|
37
|
+
self.refresh_token = refresh_token
|
|
38
|
+
self.session = session or requests.Session()
|
|
39
|
+
self.message_id = None
|
|
40
|
+
self.callback_token = None
|
|
41
|
+
self.session_key = None
|
|
42
|
+
self.status = "Idle"
|
|
43
|
+
self.status_detail = "Ready."
|
|
44
|
+
self.last_sender = "cli"
|
|
45
|
+
self.last_prompt = ""
|
|
46
|
+
self.model_name = "pycodex"
|
|
47
|
+
self.output_text = ""
|
|
48
|
+
self.error = ""
|
|
49
|
+
self.running = False
|
|
50
|
+
self.detached = False
|
|
51
|
+
self._user_access_token = None
|
|
52
|
+
self._user_token_expires_at = 0.0
|
|
53
|
+
self._tenant_token = None
|
|
54
|
+
self._tenant_token_expires_at = 0.0
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_env(cls) -> "PycodexCard":
|
|
58
|
+
api_base = os.environ.get("FEISHU_API_BASE", FEISHU_API_BASE)
|
|
59
|
+
return cls(
|
|
60
|
+
app_id=_env("FEISHU_APP_ID", "LARK_APP_ID"),
|
|
61
|
+
app_secret=_env("FEISHU_APP_SECRET", "LARK_APP_SECRET"),
|
|
62
|
+
api_base=api_base,
|
|
63
|
+
domain=os.environ.get("FEISHU_DOMAIN", _api_base_to_domain(api_base)),
|
|
64
|
+
verification_token=_env(
|
|
65
|
+
"FEISHU_VERIFICATION_TOKEN",
|
|
66
|
+
"LARK_VERIFICATION_TOKEN",
|
|
67
|
+
),
|
|
68
|
+
encrypt_key=_env("FEISHU_ENCRYPT_KEY", "LARK_ENCRYPT_KEY"),
|
|
69
|
+
refresh_token=_read_refresh_token() or os.environ.get("FEISHU_REFRESH_TOKEN"),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def configured(self) -> bool:
|
|
73
|
+
return bool(self.app_id and self.app_secret)
|
|
74
|
+
|
|
75
|
+
def send(self, target: str) -> None:
|
|
76
|
+
receive_id = str(target or "").strip()
|
|
77
|
+
if not receive_id:
|
|
78
|
+
raise ValueError("recipient target is required")
|
|
79
|
+
receive_id = self.resolve_name(receive_id)
|
|
80
|
+
if not receive_id:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
"cannot resolve Feishu user from target: {0}".format(target)
|
|
83
|
+
)
|
|
84
|
+
if receive_id.startswith("oc_"):
|
|
85
|
+
resolved_type = "chat_id"
|
|
86
|
+
else:
|
|
87
|
+
resolved_type = "open_id"
|
|
88
|
+
|
|
89
|
+
self.session_key = "feishu:manual:{0}:{1}".format(resolved_type, receive_id)
|
|
90
|
+
|
|
91
|
+
def build_body(output_mode):
|
|
92
|
+
return {
|
|
93
|
+
"receive_id": receive_id,
|
|
94
|
+
"msg_type": "interactive",
|
|
95
|
+
"content": json.dumps(self.render(output_mode), ensure_ascii=False),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
response = self._request_rendered_card(
|
|
99
|
+
"POST",
|
|
100
|
+
"/im/v1/messages",
|
|
101
|
+
{"receive_id_type": resolved_type},
|
|
102
|
+
build_body,
|
|
103
|
+
)
|
|
104
|
+
self.message_id = _extract_message_id(response)
|
|
105
|
+
|
|
106
|
+
def update(
|
|
107
|
+
self,
|
|
108
|
+
message_id: "typing.Union[str, None]" = None,
|
|
109
|
+
callback_token: "typing.Union[str, None]" = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
if message_id:
|
|
112
|
+
self.message_id = message_id
|
|
113
|
+
if callback_token:
|
|
114
|
+
self.callback_token = callback_token
|
|
115
|
+
if not self.configured():
|
|
116
|
+
return
|
|
117
|
+
if self.message_id:
|
|
118
|
+
|
|
119
|
+
def build_body(output_mode):
|
|
120
|
+
return {
|
|
121
|
+
"content": json.dumps(
|
|
122
|
+
self.render(output_mode),
|
|
123
|
+
ensure_ascii=False,
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
self._request_rendered_card(
|
|
128
|
+
"PATCH",
|
|
129
|
+
"/im/v1/messages/{0}".format(self.message_id),
|
|
130
|
+
None,
|
|
131
|
+
build_body,
|
|
132
|
+
)
|
|
133
|
+
return
|
|
134
|
+
if self.callback_token:
|
|
135
|
+
|
|
136
|
+
def build_body(output_mode):
|
|
137
|
+
return {"token": self.callback_token, "card": self.render(output_mode)}
|
|
138
|
+
|
|
139
|
+
self._request_rendered_card(
|
|
140
|
+
"POST",
|
|
141
|
+
"/interactive/v1/card/update",
|
|
142
|
+
None,
|
|
143
|
+
build_body,
|
|
144
|
+
use_user_token=False,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def set_queued(self, prompt: str, sender: str = "cli") -> None:
|
|
148
|
+
self.status = "Queued"
|
|
149
|
+
self.status_detail = "Waiting for pycodex."
|
|
150
|
+
self.last_sender = sender or "cli"
|
|
151
|
+
self.last_prompt = prompt
|
|
152
|
+
self._mark_last_turn_output()
|
|
153
|
+
self.error = ""
|
|
154
|
+
self.running = True
|
|
155
|
+
|
|
156
|
+
def set_snapshot(self, prompt: str, output: str) -> None:
|
|
157
|
+
self.last_prompt = prompt or self.last_prompt
|
|
158
|
+
self.output_text = output or self.output_text
|
|
159
|
+
|
|
160
|
+
def detach(self) -> None:
|
|
161
|
+
self.status = "Detached"
|
|
162
|
+
self.status_detail = ""
|
|
163
|
+
self.error = ""
|
|
164
|
+
self.running = False
|
|
165
|
+
self.detached = True
|
|
166
|
+
|
|
167
|
+
def apply_event(self, event) -> bool:
|
|
168
|
+
kind = getattr(event, "kind", None)
|
|
169
|
+
if kind is None and isinstance(event, dict):
|
|
170
|
+
kind = event.get("kind", "")
|
|
171
|
+
kind = str(kind or "")
|
|
172
|
+
payload = getattr(event, "payload", None)
|
|
173
|
+
if payload is None and isinstance(event, dict):
|
|
174
|
+
payload = event
|
|
175
|
+
if not isinstance(payload, dict):
|
|
176
|
+
payload = {}
|
|
177
|
+
if kind == "turn_started":
|
|
178
|
+
self.status = "Running"
|
|
179
|
+
self.status_detail = "Model request started."
|
|
180
|
+
was_running = self.running
|
|
181
|
+
self.running = True
|
|
182
|
+
prompt = payload.get("user_text") or "\n".join(
|
|
183
|
+
str(item) for item in payload.get("user_texts", []) or []
|
|
184
|
+
)
|
|
185
|
+
if prompt:
|
|
186
|
+
prompt_text = str(prompt)
|
|
187
|
+
if not (was_running and prompt_text == self.last_prompt):
|
|
188
|
+
self.last_sender = "cli"
|
|
189
|
+
self.last_prompt = prompt_text
|
|
190
|
+
self._mark_last_turn_output()
|
|
191
|
+
self.error = ""
|
|
192
|
+
return False
|
|
193
|
+
if kind == "assistant_delta":
|
|
194
|
+
self.status = "Responding"
|
|
195
|
+
self.status_detail = "Receiving assistant output."
|
|
196
|
+
# self.output_text += str(payload.get("delta", ""))
|
|
197
|
+
return False
|
|
198
|
+
if kind == "tool_started":
|
|
199
|
+
self.status = "Tool"
|
|
200
|
+
self.status_detail = str(payload.get("tool_name") or "tool")
|
|
201
|
+
return False
|
|
202
|
+
if kind == "tool_completed":
|
|
203
|
+
self.status_detail = str(
|
|
204
|
+
payload.get("summary") or payload.get("tool_name") or "tool completed"
|
|
205
|
+
)
|
|
206
|
+
return False
|
|
207
|
+
if kind == "stream_error":
|
|
208
|
+
self.status = "Retrying"
|
|
209
|
+
self.status_detail = str(
|
|
210
|
+
payload.get("summary") or payload.get("message") or ""
|
|
211
|
+
)
|
|
212
|
+
return False
|
|
213
|
+
if kind == "turn_completed":
|
|
214
|
+
final_text = str(payload.get("output_text") or "")
|
|
215
|
+
if final_text:
|
|
216
|
+
self.output_text = final_text
|
|
217
|
+
self.status = "Idle"
|
|
218
|
+
self.status_detail = "Turn completed."
|
|
219
|
+
self.running = False
|
|
220
|
+
return True
|
|
221
|
+
if kind in {"turn_failed", "submission_failed"}:
|
|
222
|
+
self.status = "Error"
|
|
223
|
+
self.error = str(payload.get("error") or kind)
|
|
224
|
+
self.status_detail = self.error
|
|
225
|
+
self.running = False
|
|
226
|
+
return True
|
|
227
|
+
if kind in {"turn_interrupted", "submission_cancelled"}:
|
|
228
|
+
self.status = "Idle"
|
|
229
|
+
self.status_detail = kind.replace("_", " ")
|
|
230
|
+
self.running = False
|
|
231
|
+
return True
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
def _mark_last_turn_output(self) -> None:
|
|
235
|
+
if self.output_text and not self.output_text.startswith(LAST_TURN_PREFIX):
|
|
236
|
+
self.output_text = LAST_TURN_PREFIX + self.output_text
|
|
237
|
+
|
|
238
|
+
def render(
|
|
239
|
+
self, output_mode: str = CARD_OUTPUT_MODE_MARKDOWN
|
|
240
|
+
) -> "typing.Dict[str, object]":
|
|
241
|
+
idle = str(self.status or "").lower() == "idle"
|
|
242
|
+
status_line = _escape_markdown(self.status)
|
|
243
|
+
if self.status_detail:
|
|
244
|
+
status_line += " - " + _escape_markdown(self.status_detail)
|
|
245
|
+
if self.error:
|
|
246
|
+
status_line += " - " + _escape_markdown(self.error)
|
|
247
|
+
input_disabled = self.running or self.detached
|
|
248
|
+
sender = _escape_markdown(self.last_sender or "cli")
|
|
249
|
+
prompt = _truncate(self.last_prompt, 1200) or "-"
|
|
250
|
+
output = _truncate(self.output_text, CARD_OUTPUT_LIMIT) or (
|
|
251
|
+
"Waiting for output..." if self.running else "Ready."
|
|
252
|
+
)
|
|
253
|
+
output_content = _render_output_content(output, output_mode)
|
|
254
|
+
color, title = _status_template(self.status)
|
|
255
|
+
card = {
|
|
256
|
+
"schema": "2.0",
|
|
257
|
+
"config": {"update_multi": True},
|
|
258
|
+
"header": {
|
|
259
|
+
"title": {"tag": "plain_text", "content": title},
|
|
260
|
+
"template": color,
|
|
261
|
+
},
|
|
262
|
+
"body": {
|
|
263
|
+
"elements": [
|
|
264
|
+
{
|
|
265
|
+
"tag": "markdown",
|
|
266
|
+
"element_id": "prompt_md",
|
|
267
|
+
"content": f"> {sender}: **{_escape_code_block(prompt)}**",
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
"tag": "column_set",
|
|
271
|
+
"background_style": "grey-50",
|
|
272
|
+
"horizontal_spacing": "8px",
|
|
273
|
+
"horizontal_align": "left",
|
|
274
|
+
"columns": [
|
|
275
|
+
{
|
|
276
|
+
"tag": "column",
|
|
277
|
+
"width": "auto",
|
|
278
|
+
"elements": [
|
|
279
|
+
{
|
|
280
|
+
"tag": "markdown",
|
|
281
|
+
"element_id": "answer_md",
|
|
282
|
+
"content": output_content,
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
"vertical_spacing": "8px",
|
|
286
|
+
"horizontal_align": "left",
|
|
287
|
+
"vertical_align": "top",
|
|
288
|
+
}
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
]
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
if not self.detached:
|
|
295
|
+
card["body"]["elements"].append(
|
|
296
|
+
{
|
|
297
|
+
"tag": "input",
|
|
298
|
+
"element_id": "prompt_input",
|
|
299
|
+
"name": "prompt",
|
|
300
|
+
"input_type": "text",
|
|
301
|
+
"width": "fill",
|
|
302
|
+
"disabled": input_disabled,
|
|
303
|
+
"placeholder": {
|
|
304
|
+
"tag": "plain_text",
|
|
305
|
+
"content": f"Ask {self.model_name}..." if idle else status_line,
|
|
306
|
+
},
|
|
307
|
+
"behaviors": [{"type": "callback", "value": {"action": "send"}}],
|
|
308
|
+
"value": {"action": "send"},
|
|
309
|
+
},
|
|
310
|
+
)
|
|
311
|
+
return card
|
|
312
|
+
|
|
313
|
+
def parse_action(self, sdk_event) -> "typing.Dict[str, object]":
|
|
314
|
+
event_data = getattr(sdk_event, "event", None)
|
|
315
|
+
action_payload = getattr(event_data, "action", None)
|
|
316
|
+
operator = getattr(event_data, "operator", None)
|
|
317
|
+
context = getattr(event_data, "context", None)
|
|
318
|
+
form_values = _sdk_form_values(action_payload)
|
|
319
|
+
value = getattr(action_payload, "value", None) or {}
|
|
320
|
+
if not isinstance(value, dict):
|
|
321
|
+
value = {}
|
|
322
|
+
prompt = str(
|
|
323
|
+
form_values.get("prompt")
|
|
324
|
+
or form_values.get("prompt_input")
|
|
325
|
+
or getattr(action_payload, "input_value", None)
|
|
326
|
+
or value.get("input_value")
|
|
327
|
+
or ""
|
|
328
|
+
).strip()
|
|
329
|
+
if not prompt and form_values:
|
|
330
|
+
prompt = str(next(iter(form_values.values()))).strip()
|
|
331
|
+
action = str(
|
|
332
|
+
value.get("action") or getattr(action_payload, "name", None) or "send"
|
|
333
|
+
).strip()
|
|
334
|
+
if action in {"send_button", "prompt_form"}:
|
|
335
|
+
action = "send"
|
|
336
|
+
message_id = (
|
|
337
|
+
str(getattr(context, "open_message_id", None) or "").strip() or None
|
|
338
|
+
)
|
|
339
|
+
callback_token = str(getattr(event_data, "token", None) or "").strip() or None
|
|
340
|
+
self.message_id = message_id or self.message_id
|
|
341
|
+
self.callback_token = callback_token or self.callback_token
|
|
342
|
+
if not self.session_key:
|
|
343
|
+
tenant_key = str(getattr(operator, "tenant_key", None) or "").strip()
|
|
344
|
+
open_id = str(getattr(operator, "open_id", None) or "").strip()
|
|
345
|
+
self.session_key = _default_session_key(tenant_key, open_id, message_id)
|
|
346
|
+
return {
|
|
347
|
+
"action": action,
|
|
348
|
+
"prompt": prompt,
|
|
349
|
+
"sender": self.resolve_operator_name(operator),
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
def resolve_name(self, name: str) -> "typing.Union[str, None]":
|
|
353
|
+
normalized = str(name or "").strip()
|
|
354
|
+
if not normalized:
|
|
355
|
+
return None
|
|
356
|
+
aliases = _user_id_aliases()
|
|
357
|
+
if normalized in aliases:
|
|
358
|
+
return aliases[normalized]
|
|
359
|
+
if normalized.startswith("ou_"):
|
|
360
|
+
return normalized
|
|
361
|
+
if normalized.startswith("oc_"):
|
|
362
|
+
return normalized
|
|
363
|
+
if normalized.isnumeric():
|
|
364
|
+
return self._lookup_user({"mobiles": [normalized]})
|
|
365
|
+
if "@" not in normalized:
|
|
366
|
+
email_domain = _default_email_domain()
|
|
367
|
+
if not email_domain:
|
|
368
|
+
return None
|
|
369
|
+
normalized = normalized + "@" + email_domain
|
|
370
|
+
return self._lookup_user({"emails": [normalized]})
|
|
371
|
+
|
|
372
|
+
def _lookup_user(
|
|
373
|
+
self, body: "typing.Dict[str, object]"
|
|
374
|
+
) -> "typing.Union[str, None]":
|
|
375
|
+
payload = dict(body)
|
|
376
|
+
payload["include_resigned"] = False
|
|
377
|
+
response = self._request(
|
|
378
|
+
"POST",
|
|
379
|
+
"/contact/v3/users/batch_get_id",
|
|
380
|
+
params={"user_id_type": "open_id"},
|
|
381
|
+
json_body=payload,
|
|
382
|
+
use_user_token=False,
|
|
383
|
+
)
|
|
384
|
+
user_list = _dig(response, "data", "user_list")
|
|
385
|
+
if not isinstance(user_list, list) or not user_list:
|
|
386
|
+
return None
|
|
387
|
+
item = user_list[0] if isinstance(user_list[0], dict) else {}
|
|
388
|
+
value = item.get("user_id") or item.get("open_id") or item.get("union_id")
|
|
389
|
+
return str(value).strip() if value else None
|
|
390
|
+
|
|
391
|
+
def resolve_operator_name(self, operator) -> str:
|
|
392
|
+
for user_id_type in ("user_id", "open_id", "union_id"):
|
|
393
|
+
user_id = str(getattr(operator, user_id_type, None) or "").strip()
|
|
394
|
+
if not user_id:
|
|
395
|
+
continue
|
|
396
|
+
try:
|
|
397
|
+
response = self._request(
|
|
398
|
+
"GET",
|
|
399
|
+
"/contact/v3/users/{0}".format(user_id),
|
|
400
|
+
params={"user_id_type": user_id_type},
|
|
401
|
+
use_user_token=False,
|
|
402
|
+
)
|
|
403
|
+
except Exception:
|
|
404
|
+
continue
|
|
405
|
+
name = _display_user_name(_dig(response, "data", "user") or response)
|
|
406
|
+
if name:
|
|
407
|
+
return name
|
|
408
|
+
return "cli"
|
|
409
|
+
|
|
410
|
+
def _request(
|
|
411
|
+
self,
|
|
412
|
+
method: str,
|
|
413
|
+
path: str,
|
|
414
|
+
params: "typing.Union[typing.Dict[str, str], None]" = None,
|
|
415
|
+
json_body: "typing.Union[typing.Dict[str, object], None]" = None,
|
|
416
|
+
use_user_token: bool = True,
|
|
417
|
+
) -> "typing.Dict[str, object]":
|
|
418
|
+
user_token = self.user_access_token() if use_user_token else None
|
|
419
|
+
token = user_token or self.tenant_access_token()
|
|
420
|
+
response = self.session.request(
|
|
421
|
+
method,
|
|
422
|
+
self.api_base.rstrip("/") + path,
|
|
423
|
+
params=params,
|
|
424
|
+
json=json_body,
|
|
425
|
+
headers={"Authorization": "Bearer {0}".format(token)},
|
|
426
|
+
timeout=20,
|
|
427
|
+
)
|
|
428
|
+
return _checked_json_response(response)
|
|
429
|
+
|
|
430
|
+
def _request_rendered_card(
|
|
431
|
+
self,
|
|
432
|
+
method: str,
|
|
433
|
+
path: str,
|
|
434
|
+
params: "typing.Union[typing.Dict[str, str], None]",
|
|
435
|
+
json_body_builder: "typing.Callable[[str], typing.Dict[str, object]]",
|
|
436
|
+
use_user_token: bool = True,
|
|
437
|
+
) -> "typing.Dict[str, object]":
|
|
438
|
+
try:
|
|
439
|
+
return self._request(
|
|
440
|
+
method,
|
|
441
|
+
path,
|
|
442
|
+
params=params,
|
|
443
|
+
json_body=json_body_builder(CARD_OUTPUT_MODE_MARKDOWN),
|
|
444
|
+
use_user_token=use_user_token,
|
|
445
|
+
)
|
|
446
|
+
except Exception as exc:
|
|
447
|
+
if not _is_card_content_error(exc):
|
|
448
|
+
raise
|
|
449
|
+
return self._request(
|
|
450
|
+
method,
|
|
451
|
+
path,
|
|
452
|
+
params=params,
|
|
453
|
+
json_body=json_body_builder(CARD_OUTPUT_MODE_CODE),
|
|
454
|
+
use_user_token=use_user_token,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
def user_access_token(self) -> "typing.Union[str, None]":
|
|
458
|
+
refresh_token = _read_refresh_token() or self.refresh_token
|
|
459
|
+
if not refresh_token:
|
|
460
|
+
return None
|
|
461
|
+
self.refresh_token = refresh_token
|
|
462
|
+
now = time.time()
|
|
463
|
+
if self._user_access_token and now < self._user_token_expires_at:
|
|
464
|
+
return self._user_access_token
|
|
465
|
+
if not self.configured():
|
|
466
|
+
raise RuntimeError("FEISHU_APP_ID and FEISHU_APP_SECRET are required")
|
|
467
|
+
response = self.session.post(
|
|
468
|
+
self.api_base.rstrip("/") + "/authen/v2/oauth/token",
|
|
469
|
+
json={
|
|
470
|
+
"grant_type": "refresh_token",
|
|
471
|
+
"client_id": self.app_id,
|
|
472
|
+
"client_secret": self.app_secret,
|
|
473
|
+
"refresh_token": refresh_token,
|
|
474
|
+
},
|
|
475
|
+
timeout=20,
|
|
476
|
+
)
|
|
477
|
+
payload = _checked_json_response(response)
|
|
478
|
+
token = payload.get("access_token")
|
|
479
|
+
if not token:
|
|
480
|
+
raise RuntimeError("user access_token missing from Feishu response")
|
|
481
|
+
self._user_access_token = str(token)
|
|
482
|
+
expires_in = payload.get("expires_in") or 0
|
|
483
|
+
self._user_token_expires_at = time.time() + max(60, int(expires_in) - 120)
|
|
484
|
+
refresh_token = payload.get("refresh_token")
|
|
485
|
+
if refresh_token:
|
|
486
|
+
self.refresh_token = str(refresh_token)
|
|
487
|
+
os.environ["FEISHU_REFRESH_TOKEN"] = self.refresh_token
|
|
488
|
+
_write_refresh_token(self.refresh_token)
|
|
489
|
+
return self._user_access_token
|
|
490
|
+
|
|
491
|
+
def tenant_access_token(self) -> str:
|
|
492
|
+
now = time.time()
|
|
493
|
+
if self._tenant_token and now < self._tenant_token_expires_at:
|
|
494
|
+
return self._tenant_token
|
|
495
|
+
response = self.session.post(
|
|
496
|
+
self.api_base.rstrip("/") + "/auth/v3/tenant_access_token/internal",
|
|
497
|
+
json={"app_id": self.app_id, "app_secret": self.app_secret},
|
|
498
|
+
timeout=20,
|
|
499
|
+
)
|
|
500
|
+
payload = _checked_json_response(response)
|
|
501
|
+
token = _dig(payload, "tenant_access_token") or _dig(
|
|
502
|
+
payload,
|
|
503
|
+
"data",
|
|
504
|
+
"tenant_access_token",
|
|
505
|
+
)
|
|
506
|
+
if not token:
|
|
507
|
+
raise RuntimeError("tenant_access_token missing from Feishu response")
|
|
508
|
+
expires_in = _dig(payload, "expire") or _dig(payload, "data", "expire") or 3600
|
|
509
|
+
self._tenant_token = str(token)
|
|
510
|
+
self._tenant_token_expires_at = now + max(60, int(expires_in) - 120)
|
|
511
|
+
return self._tenant_token
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _sdk_form_values(action_payload: "typing.Any") -> "typing.Dict[str, object]":
|
|
515
|
+
for name in ("form_value", "form_values", "input_values"):
|
|
516
|
+
value = getattr(action_payload, name, None)
|
|
517
|
+
if isinstance(value, dict):
|
|
518
|
+
return value
|
|
519
|
+
value = getattr(action_payload, "value", None)
|
|
520
|
+
if isinstance(value, dict):
|
|
521
|
+
for name in ("form_value", "form_values", "input_values"):
|
|
522
|
+
nested = value.get(name)
|
|
523
|
+
if isinstance(nested, dict):
|
|
524
|
+
return nested
|
|
525
|
+
return {}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _checked_json_response(response) -> "typing.Dict[str, object]":
|
|
529
|
+
try:
|
|
530
|
+
payload = response.json()
|
|
531
|
+
except Exception as exc:
|
|
532
|
+
raise RuntimeError(
|
|
533
|
+
"Feishu API returned non-JSON response: status={0} body={1}".format(
|
|
534
|
+
getattr(response, "status_code", "?"),
|
|
535
|
+
getattr(response, "text", ""),
|
|
536
|
+
)
|
|
537
|
+
) from exc
|
|
538
|
+
if getattr(response, "status_code", 200) >= 400:
|
|
539
|
+
raise RuntimeError(
|
|
540
|
+
"Feishu API error: status={0} body={1}".format(
|
|
541
|
+
response.status_code,
|
|
542
|
+
json.dumps(payload, ensure_ascii=False),
|
|
543
|
+
)
|
|
544
|
+
)
|
|
545
|
+
code = payload.get("code")
|
|
546
|
+
if code not in (None, 0):
|
|
547
|
+
raise RuntimeError(
|
|
548
|
+
"Feishu API error: {0}".format(json.dumps(payload, ensure_ascii=False))
|
|
549
|
+
)
|
|
550
|
+
return payload
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _extract_message_id(
|
|
554
|
+
response: "typing.Dict[str, object]",
|
|
555
|
+
) -> "typing.Union[str, None]":
|
|
556
|
+
for path in (
|
|
557
|
+
("data", "message_id"),
|
|
558
|
+
("data", "message", "message_id"),
|
|
559
|
+
("message_id",),
|
|
560
|
+
("open_message_id",),
|
|
561
|
+
):
|
|
562
|
+
value = _dig(response, *path)
|
|
563
|
+
if value:
|
|
564
|
+
return str(value)
|
|
565
|
+
return None
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _default_session_key(
|
|
569
|
+
tenant_key: str,
|
|
570
|
+
open_id: str,
|
|
571
|
+
message_id: "typing.Union[str, None]",
|
|
572
|
+
) -> str:
|
|
573
|
+
user_key = open_id or "unknown"
|
|
574
|
+
if message_id:
|
|
575
|
+
return "feishu:{0}:{1}:{2}".format(tenant_key or "tenant", user_key, message_id)
|
|
576
|
+
return "feishu:{0}:{1}:default".format(tenant_key or "tenant", user_key)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _display_user_name(user: "typing.Any") -> "typing.Union[str, None]":
|
|
580
|
+
if not isinstance(user, dict):
|
|
581
|
+
return None
|
|
582
|
+
for key in ("name", "en_name", "nickname", "email", "enterprise_email"):
|
|
583
|
+
value = str(user.get(key) or "").strip()
|
|
584
|
+
if value:
|
|
585
|
+
return value
|
|
586
|
+
return None
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _status_template(status: str) -> "typing.Tuple[str, str]":
|
|
590
|
+
normalized = status.lower()
|
|
591
|
+
if normalized in {"error", "failed"}:
|
|
592
|
+
return "red", "Session Connected"
|
|
593
|
+
if normalized in {"running", "responding", "tool", "queued", "retrying"}:
|
|
594
|
+
return "blue", "Session Connected"
|
|
595
|
+
if normalized in {"detached"}:
|
|
596
|
+
return "grey", "Session Detached"
|
|
597
|
+
return "green", "Session Connected"
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _truncate(text: str, limit: int) -> str:
|
|
601
|
+
value = str(text or "")
|
|
602
|
+
if len(value) <= limit:
|
|
603
|
+
return value
|
|
604
|
+
return value[: max(0, limit - 32)] + "\n...[truncated]"
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _escape_code_block(text: str) -> str:
|
|
608
|
+
return str(text or "").replace("```", "'''")
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _render_output_content(output: str, output_mode: str) -> str:
|
|
612
|
+
if output_mode == CARD_OUTPUT_MODE_CODE:
|
|
613
|
+
return "```text\n{0}\n```".format(_escape_code_block(output))
|
|
614
|
+
return output
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _is_card_content_error(exc: "BaseException") -> bool:
|
|
618
|
+
return CARD_CONTENT_ERROR_MARKER in str(exc)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _escape_markdown(text: str) -> str:
|
|
622
|
+
return str(text or "").replace("`", "'")
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _dig(value: "typing.Any", *path: str) -> "typing.Any":
|
|
626
|
+
current = value
|
|
627
|
+
for key in path:
|
|
628
|
+
if not isinstance(current, dict):
|
|
629
|
+
return None
|
|
630
|
+
current = current.get(key)
|
|
631
|
+
return current
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _env(*names: str) -> "typing.Union[str, None]":
|
|
635
|
+
for name in names:
|
|
636
|
+
value = os.environ.get(name)
|
|
637
|
+
if value:
|
|
638
|
+
return value
|
|
639
|
+
return None
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _refresh_token_path() -> Path:
|
|
643
|
+
return Path(FEISHU_REFRESH_TOKEN_FILE).expanduser()
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _read_refresh_token() -> "typing.Union[str, None]":
|
|
647
|
+
path = _refresh_token_path()
|
|
648
|
+
if not path.exists():
|
|
649
|
+
return None
|
|
650
|
+
value = path.read_text(encoding="utf-8", errors="replace").strip()
|
|
651
|
+
return value or None
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _write_refresh_token(value: str) -> None:
|
|
655
|
+
path = _refresh_token_path()
|
|
656
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
657
|
+
path.write_text("{0}\n".format(value), encoding="utf-8")
|
|
658
|
+
try:
|
|
659
|
+
path.chmod(0o600)
|
|
660
|
+
except OSError:
|
|
661
|
+
pass
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _api_base_to_domain(api_base: str) -> str:
|
|
665
|
+
base = str(api_base or FEISHU_API_BASE).rstrip("/")
|
|
666
|
+
marker = "/open-apis"
|
|
667
|
+
if base.endswith(marker):
|
|
668
|
+
return base[: -len(marker)]
|
|
669
|
+
return base
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _default_email_domain() -> "typing.Union[str, None]":
|
|
673
|
+
value = _env(
|
|
674
|
+
"PYCODEX_FEISHU_DEFAULT_EMAIL_DOMAIN",
|
|
675
|
+
"FEISHU_DEFAULT_EMAIL_DOMAIN",
|
|
676
|
+
"LARK_DEFAULT_EMAIL_DOMAIN",
|
|
677
|
+
)
|
|
678
|
+
if not value:
|
|
679
|
+
return None
|
|
680
|
+
return str(value).strip().lstrip("@") or None
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _user_id_aliases() -> "typing.Dict[str, str]":
|
|
684
|
+
raw = os.environ.get("PYCODEX_FEISHU_USER_IDS")
|
|
685
|
+
if not raw:
|
|
686
|
+
return {}
|
|
687
|
+
try:
|
|
688
|
+
payload = json.loads(raw)
|
|
689
|
+
except Exception:
|
|
690
|
+
return {}
|
|
691
|
+
if not isinstance(payload, dict):
|
|
692
|
+
return {}
|
|
693
|
+
return {str(key): str(value) for key, value in payload.items() if key and value}
|